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 Twitter/X, Instagram, WhatsApp, TikTok, LinkedIn,
Facebook, YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business, Snapchat,
Discord, and Slack, all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google,
TikTok, LinkedIn, Pinterest, X, and OpenAI from the same account.
Key features: Unified posting to 16 platforms, ads management on 7 ad networks (via /v1/ads), aggregated analytics, unified inbox (DMs, comments, reviews), webhooks, OAuth connect, queue scheduling, and white-label support for agencies managing unlimited accounts.
Supported posting platforms: Twitter/X, Instagram, WhatsApp, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business, Telegram, Snapchat, Discord, Slack. Supported ad platforms: Meta Ads, Google Ads, TikTok Ads, LinkedIn Ads, Pinterest Ads, X Ads, OpenAI Ads.
x-category: Social
x-website: https://zernio.com
x-thumbnail: https://rapidapi-prod-apis.s3.amazonaws.com/b24d3df5-563c-4a50-9e1e-1ad3eb1fce69.png
x-version-lifecycle: ACTIVE
x-badges:
- name: "social media"
value: "social media"
- name: "scheduling"
value: "scheduling"
- name: "instagram"
value: "instagram"
- name: "tiktok"
value: "tiktok"
- name: "twitter"
value: "twitter"
- name: "linkedin"
value: "linkedin"
- name: "facebook"
value: "facebook"
- name: "youtube"
value: "youtube"
- name: "social media api"
value: "social media api"
- name: "posting"
value: "posting"
# RapidAPI Hub documentation tab (README)
x-documentation:
readme: |
# Zernio API
The social media API that replaces 14 integrations. Build social media features into your app in minutes, not months.
## Quick Start
**Base URL:** `https://zernio.com/api/v1`
**Authentication:** All requests require a Bearer API key in the `Authorization` header.
```bash
curl https://zernio.com/api/v1/user \
-H "Authorization: Bearer YOUR_API_KEY"
```
Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys).
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Profiles** | Containers that organize social accounts into brands or projects |
| **Accounts** | Connected social media accounts belonging to a profile |
| **Posts** | Content scheduled or published to one or more accounts |
| **Queue** | Recurring time slots for automatic post scheduling |
## Create a Post
```bash
curl -X POST https://zernio.com/api/v1/post \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"profileId": "your-profile-id",
"text": "Hello from Zernio API!",
"socialAccountIds": ["account-1", "account-2"],
"scheduledAt": "2025-01-15T10:00:00Z"
}'
```
This single call publishes or schedules the post to all selected accounts across any platform.
## Supported Platforms
| Platform | Post | Stories/Reels | Analytics | Inbox |
|----------|------|---------------|-----------|-------|
| Twitter/X | Yes | - | Yes | Yes |
| Instagram | Yes | Yes | Yes | Yes |
| Facebook | Yes | Stories | Yes | Yes |
| LinkedIn | Yes | - | Partial | - |
| TikTok | Yes | - | Yes | - |
| YouTube | Yes | Shorts | Yes | Yes |
| Pinterest | Yes | - | Yes | - |
| Reddit | Yes | - | Partial | Yes |
| Bluesky | Yes | - | Partial | Yes |
| Threads | Yes | - | Yes | Yes |
| Google Business | Yes | - | - | Yes |
| Telegram | Yes | - | - | - |
| Snapchat | Yes | - | - | - |
> **LinkedIn Analytics Note:** For personal LinkedIn accounts, analytics are only available for posts published through Zernio. This is a LinkedIn API limitation: the `memberCreatorPostAnalytics` endpoint only returns metrics for posts authored by the authenticated user. Company/organization page analytics are not affected and work for all posts.
> **Google Business Analytics Note:** Per-post analytics for Google Business Profile are deprecated by Google with no replacement, so Google Business posts always report `syncStatus: "unavailable"` with an explanatory `errorMessage`. Location-level metrics (impressions, clicks, calls, directions, bookings) are available via the dedicated `/v1/analytics/googlebusiness/performance` endpoint.
## Rate Limits
API request throughput is rate-limited per minute on a sliding window. Limits scale with your team's total connected social accounts:
- **0–2 accounts** (free tier): 60 req/min
- **3–2,000 accounts**: 600 req/min
- **2,001+ accounts**: 1,200 req/min
Legacy AppSumo lifetime tiers get a flat 600 req/min regardless of tier.
Posts themselves are unlimited on every connected social account; the rate limit applies only to API request throughput. Zernio also applies per-account daily publish caps as an anti-abuse safeguard (Instagram 100/day, Facebook 100/day, Threads 250/day, X/Twitter 50/day, Pinterest 25/day, 50/day for every other platform), plus a 25-posts-per-hour per-account velocity cap.
All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers — read these instead of hard-coding limits, since your tier may be configured higher than the default.
## Webhooks
Receive real-time notifications for post status changes, account events, and incoming messages:
- `post.scheduled` - Post entered the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed post retried, or a recycled clone created
- `post.published` - Post successfully published
- `post.failed` - Post failed on all platforms
- `post.partial` - Post published to some platforms, failed on others
- `post.cancelled` - Post publishing was cancelled
- `post.recycled` - Post recycled (cloned and re-scheduled)
- `post.platform.published` - A single platform target inside a post finished publishing successfully. Fires once per platform-account as it terminates, without waiting for other platforms on the same post. Use this for incremental UIs; use `post.published` for the post-level rollup.
- `post.platform.failed` - A single platform target inside a post failed permanently. Temporary/retryable failures do NOT fire this event, only permanent ones, so retry loops stay quiet.
- `post.platform.deleted` - A single platform target inside a post was detected as deleted on the platform after publishing (e.g. the user deleted the Instagram post natively). Detection is poll-driven via Zernio background sync (~hourly), not real-time. `platform.deletedAt` carries the detection time. Coverage is bounded to the posts the platform listing returns.
- `post.tiktok.url_resolved` - A published TikTok post's public URL became available. TikTok exposes the numeric video id asynchronously, so `post.published` / `post.platform.published` can carry an empty `publishedUrl` for TikTok; this event delivers the URL once resolved (at most once per platform target). Never fires for drafts or private posts, which have no public URL.
- `post.external.created` - A native post authored outside Zernio (e.g. a Google Business Profile localPost created in the Google UI) was detected by our sync for the first time. Poll-driven (~hourly), not real-time. Payload carries `post.source: "external"`.
- `post.external.updated` - A tracked native post's text or media changed on the platform. Edits are detected by comparing text/media structure and (where the platform exposes it) the platform's own edit timestamp; media-URL-only refreshes do not fire this.
- `post.external.deleted` - A tracked native post was detected as removed from the platform. `post.deletedAt` carries the detection time. Coverage is bounded to the most recent posts the platform listing returns.
- `account.connected` - Social account connected
- `account.disconnected` - Social account disconnected (token expired)
- `account.ads.initial_sync_completed` - Initial ads sync (discovery + 90-day backfill) completed for an ads-enabled account
- `message.received` - New 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
- `message.sent` - DM sent via the API
- `message.edited` - A sender edited a message (Instagram, Messenger, Telegram)
- `message.deleted` - A sender deleted ("unsent") a message (Instagram; WhatsApp 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
- `review.new` - New review posted on a connected account (Google Business Profile)
- `review.updated` - Review updated or reply added (Google Business Profile, or via reply API)
- `lead.received` - New lead submitted against a Meta Lead Gen (Instant) Form. `lead.fields` is the question-key → answer map; `lead.formId`/`lead.adId` give provenance.
- `ad.status_changed` - Ad, ad set, or campaign changed status on the ad platform (Meta: `in_process_ad_objects` entry/exit and `with_issues_ad_objects` violations)
- `whatsapp.template.status_updated` - WhatsApp Business template completed (re)review by Meta. `template.status` carries the new state (APPROVED, REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION); `template.reason` is Meta's free-form reason or "NONE".
- `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
- `whatsapp.number.kyc_submitted` - An end customer completed a hosted KYC share link; the number entered regulatory review under your account
- `whatsapp.number.activated` - A provisioned WhatsApp number finished setup and is ready to connect
- `whatsapp.number.declined` - A regulated number order was declined in review; nothing activates and nothing is billed
- `whatsapp.number.action_required` - The regulator asked for more information on a placed number order; the order stays pending until provided
- `whatsapp.number.verification_required` - A regulated number needs end-user ID verification; carries the link to forward
- `whatsapp.number.suspended` - An active number was suspended (e.g. failed payment); carries a `reason`
- `whatsapp.number.reactivated` - A suspended number is usable again
- `whatsapp.number.released` - A number was released and is no longer usable (terminal); carries a `reason`
- `verification.approved` - A managed-OTP verification was approved (the user submitted the correct code)
- `verification.failed` - A managed-OTP verification was exhausted after too many wrong code attempts
- `webhook.test` - Test event sent when verifying a webhook endpoint
Webhook payloads are signed with HMAC-SHA256 via the `X-Zernio-Signature` header.
## Full Documentation
For complete guides, platform-specific details, and SDK references, visit [docs.zernio.com](https://docs.zernio.com).
## SDKs
Official SDKs available for: [Node.js](https://www.npmjs.com/package/@zernio/node), [Python](https://pypi.org/project/zernio-sdk), Go, Ruby, Java, PHP, .NET, and Rust.
servers:
- url: https://zernio.com/api
description: Production
- url: http://localhost:3000/api
description: Local
tags:
- name: Posts
description: |
Create, schedule, list, update, and delete posts across all connected social accounts.
- name: 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 Metronome's invoice breakdown (also served
at `GET /v1/usage/daily`). The `/v1/usage/<domain>` spokes (calls, sms)
meter per-domain consumption volumes over a window. `GET /v1/billing` is
the billing statement (balance, credits, caps, payment status).
- name: Profiles
description: |
Manage profiles (named groups of social accounts).
- name: Accounts
description: |
Manage connected social media accounts: list, fetch, update, disconnect, and read account health.
- name: Account Groups
description: |
Manage account groups (collections of accounts used for cross-posting and organization).
- name: API Keys
description: |
Create, list, and revoke API keys used to authenticate requests.
Resource groups. A key can opt out of any of the ten groups (publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks) via `disabledResourceGroups` at creation. Omit the field for a legacy full-access key. A key with any group disabled mints with the `zrk_` prefix, is refused on operations in those groups with 403 `insufficient_permissions` plus `required_group`, and can never manage API keys, invites, connected apps, or member identity. Each operation publishes its group as `x-resource-group`. There is no update endpoint: duplicate the key with different groups and revoke the old one.
- name: Connected Apps
description: |
List and revoke the OAuth clients (AI assistants and MCP connectors) authorized
on the account.
Connector tokens resolve against the same resource-group registry as API keys, but there is no way to narrow a connector's groups yet. Treat an authorized connector as full account access and revoke it if that is not what you want.
- name: Invites
description: |
Generate invite tokens for adding members to a team.
- name: Connect
description: |
OAuth and credential flows for connecting social accounts, plus per-platform selection
steps (Facebook pages, Pinterest boards, LinkedIn organizations, GMB locations, etc.).
- name: Media
description: |
Upload and presign media (images, videos, documents) for use in posts.
- name: Reddit Search
description: |
Search Reddit posts and browse subreddit feeds.
- name: GMB Reviews
description: |
Google Business Profile reviews: list reviews and reply to them.
- name: GMB Food Menus
description: |
Read and update Google Business Profile food menus.
- name: GMB Location Details
description: |
Read and update Google Business Profile location details.
- name: GMB Media
description: |
Manage Google Business Profile media (photos and videos): list, upload, and delete.
- name: GMB Attributes
description: |
Read and update Google Business Profile attributes.
- name: GMB Place Actions
description: |
Manage Google Business Profile place action links (booking, ordering, reservations, etc.).
- name: Discord
description: |
Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.
- name: LinkedIn Mentions
description: |
Resolve LinkedIn organization and person mentions for use in posts.
- name: Instagram
description: |
Instagram-specific read endpoints: list a connected account's Stories and fetch
per-Story insights. All endpoints require an accountId parameter identifying the
Instagram-connected social account.
- name: Queue
description: |
Manage posting-queue time slots and preview the upcoming queue.
- name: Analytics
description: |
Post and account analytics across platforms (insights, demographics, follower history,
best time to post, content decay, and aggregated metrics).
- name: Inbox Access
description: |
Check and manage inbox feature access.
- name: Messages
description: |
Unified inbox API for managing conversations and direct messages across all connected accounts.
All endpoints aggregate data from multiple social accounts in a single API call.
Requires Inbox addon.
- name: Comments
description: |
Unified inbox API for managing comments on posts across all connected accounts.
Supports commenting on third-party posts for platforms that allow it (YouTube, Twitter, Reddit, Bluesky, Threads).
All endpoints aggregate data from multiple social accounts in a single API call.
Requires Inbox addon.
- name: Reviews
description: |
Unified inbox API for managing reviews on Facebook Pages and Google Business accounts.
All endpoints aggregate data from multiple social accounts in a single API call.
Requires Inbox addon.
- name: Mentions
description: |
Unified inbox API for managing mentions across connected accounts.
Currently supports LinkedIn organization mentions.
Requires Inbox addon.
- name: Twitter Engagement
description: |
X/Twitter-specific engagement endpoints for retweeting, bookmarking, and following.
Rate limits: 50 requests per 15-min window per user. Retweets share the 300/3hr creation limit with tweet creation.
- name: Validate
description: |
Pre-flight validation endpoints. Check post content, character limits, media URLs, and subreddit existence before publishing.
- name: Account Settings
description: |
Platform-specific account settings: Facebook persistent menu, Instagram ice breakers, and Telegram bot commands.
- name: Contacts
description: |
Cross-platform contact management (CRM). Contacts are unified identities linked to platform-specific
channels (phone, IGSID, etc.). Created automatically when messages arrive, or manually via API.
- name: Custom Fields
description: |
Custom field definitions for contacts. Define fields (text, number, date, boolean, select) that can be
set on any contact for segmentation and personalization.
- name: Broadcasts
description: |
Platform-agnostic broadcast campaigns. Send bulk messages to contacts via any inbox platform.
WhatsApp broadcasts use templates; other platforms use generic messages.
- name: Sequences
description: |
Drip campaign sequences. Send a series of messages to enrolled contacts with configurable delays
between steps. Supports auto-exit on reply or unsubscribe.
- name: Workflows
description: |
Branching conversation automations. An inbound message matches a workflow's trigger and walks a
directed graph of nodes (send message, wait for reply, condition, set variable, delay, webhook,
handoff, end). Unlike Sequences (linear, time-based drips), Workflows are event-driven and
interactive. Fully supported on WhatsApp, Instagram, and Messenger; `send_message` template and
interactive modes are WhatsApp-only.
- name: Comment Automations
description: |
Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook so
commenters automatically receive a DM. Scope per post or account-wide (omit
`platformPostId` to match comments on every post on the account, with unlimited
automations stacked per account). Supports dedup, optional public comment reply, and
auto-creates contacts.
- name: Ad Campaigns
description: |
The advertising structure: campaigns, ad sets, and ads. Create standalone campaigns/ads, boost an
organic post, duplicate at any level, pause/resume, and read the full tree and timeline. Campaigns
are virtual aggregations of ads grouped by their platform campaign ID.
Per-operation platform support is shown by the platform badges. Requires the Ads add-on.
- name: Ad Creatives
description: |
Creative assets: the standalone creative library (create/reuse/rename), the ad-account image library
(list + base64 upload), rendered ad previews, and product catalogs for Advantage+/dynamic ads. Meta only.
Requires the Ads add-on.
- name: Ad Audiences
description: |
Custom audiences for targeting: customer lists (hashed upload), website + engagement + lookalike
audiences, and reusable saved-targeting presets. Creation support varies by platform (badges show which);
LinkedIn adds company-list and engagement segments.
Requires the Ads add-on.
- name: Ad Targeting
description: |
Targeting discovery: search interests/behaviors/geo/demographics, estimate reach, and (LinkedIn)
bid pricing and supply forecasts. Feeds the targeting fields on create.
Requires the Ads add-on.
- name: Ad Insights
description: |
Measurement: cached aggregate analytics per ad/campaign, plus live Meta Graph insight queries
(arbitrary fields, breakdowns, filtering, attribution windows) and async report runs.
Requires the Ads add-on.
- name: Conversions
description: |
Server-side Conversions API: send + adjust conversion events (with hashed matching and consent/LDU
forwarding), read Event Match Quality, and manage conversion destinations (pixels/datasets) and their
ad-account associations. Supported on Meta, Google, TikTok and LinkedIn (badges show per operation).
Requires the Ads add-on.
- name: Messaging Ads
description: |
Click-to-message and click-to-call destination ads: WhatsApp (CTWA), Messenger, Instagram Direct,
and Call ads. Meta only.
Requires the Ads add-on.
- name: Reach and Frequency
description: |
Fixed-price reserved (Reach & Frequency) buying: quote a prediction, reserve price + inventory, and
buy via a RESERVED campaign. Meta only.
Requires the Ads add-on.
- name: Lead Gen
description: |
Instant lead forms on Facebook Pages: create/list/archive forms and retrieve (or test) their leads.
Meta only.
Requires the Ads add-on.
- name: Ad Accounts
description: |
Ad accounts and operational/diagnostic reads: list accounts, account finances, change/audit log,
A/B studies, high-demand periods, ad labels, DSA defaults + recommendations, and Business Managers
(Meta) / Business Centers (TikTok).
Requires the Ads add-on.
- name: Tracking Tags
description: |
Manage the platform measurement tag — the thing you create, install on a website, send events to,
and target ads against. On Meta this is a Pixel; the surface is platform-neutral so other platforms
(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without changing the contract. Create a tag,
get it (including the install code snippet), rename + adjust matching/cookie/data-use settings, share
it with ad accounts, and read aggregated event stats. Currently Meta-only.
Requires the Ads add-on.
- name: Webhooks
description: |
Configure webhooks for real-time notifications. Webhooks can be created from the dashboard (Settings → Webhooks) or via this API.
Events: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, 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, verification.approved, verification.failed, webhook.test.
Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported.
Resource groups. Every event belongs to one group: post.* to publishing, message.*, reaction.*, conversation.*, call.* and whatsapp.automatic_event to messages, comment.* and review.* to engagement, lead.* to contacts, ad.* to ads, account.*, whatsapp.template.* and whatsapp.account.* to accounts, whatsapp.number.* and verification.* to telephony, webhook.test to webhooks. Two independent controls use that mapping. (1) A restricted (zrk_) API key can only subscribe to, test-fire, redeliver, or read delivery logs for events in the groups it holds, so it can never create a subscription broader than itself. (2) Each subscription carries its own `disabledResourceGroups` denylist: events in a disabled group are dropped before delivery to that endpoint, on live delivery and on every replay path, no matter which key or session created the subscription. Changing that denylist applies to every event emitted after the change; events already queued when it landed can still be delivered for up to five minutes after they were enqueued, because the delivery worker trusts a five-minute enqueue-time snapshot before re-checking. Absent or empty on a subscription means it receives everything it subscribes to, which is the behavior of every subscription created before the field existed.
- name: Webhook Events
description: |
Incoming webhook deliveries sent by Zernio to your configured endpoint URL.
- name: Logs
description: |
Publishing logs for transparency and debugging. Each log includes the platform API endpoint, HTTP status code, request/response bodies, duration, and retry attempts. Logs are automatically deleted after 7 days.
- name: WhatsApp
description: |
WhatsApp Business API. Template, business profile, and phone number endpoints.
All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
- name: WhatsApp Calling
description: |
Voice calling over the WhatsApp Business API: enable/disable calling on a number,
configure call hours and permissions, and place or list calls.
All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
- name: WhatsApp Templates
description: |
Browse Meta's pre-approved WhatsApp template library. Use these read-only lookups to
discover library templates you can import as your own message templates.
All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
- name: WhatsApp Flows
description: |
WhatsApp Flows let you build native interactive forms, surveys, and booking experiences inside WhatsApp.
Flows are created in DRAFT status, populated with a Flow JSON definition, then published for sending.
Published flows are immutable; to update, create a new flow (optionally cloning the old one).
All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
- name: Phone Numbers
description: |
Buy and manage phone numbers. A phone number is a platform-independent unit;
WhatsApp, SMS, and Voice are features you enable on it. Purchase, search
inventory, complete country KYC, and release numbers here; turn features on
via the per-number sub-resources (`/v1/phone-numbers/{id}/voice`, `/sms`,
`/whatsapp/...`). Requires a paid plan.
- name: Verify
description: |
Managed one-time passcodes (OTP) for phone verification. Two calls
handle the whole lifecycle: create a verification (we generate the
code, deliver it by SMS from a phone number on your account, and store
only its hash) and check the code the user typed. Usage-based billing
only.
- name: WhatsApp Phone Numbers
description: |
Deprecated namespace: phone numbers moved to `/v1/phone-numbers` (a number is
platform-independent; WhatsApp is one feature on it). These aliases keep
working with the same contracts, but new integrations should use the
Phone Numbers endpoints.
- name: WhatsApp Sandbox
description: |
Shared WhatsApp sandbox: a Zernio-owned WhatsApp number every user can test against
without provisioning their own. Send the verified sandbox template to phones you
activate via a reply-based verification flow. Designed for testing message flows,
bot replies, and webhook payloads end-to-end with zero number-purchase overhead.
- name: Calls
description: |
Unified call history across every number you own: WhatsApp Business Calling and
regular phone (PSTN) calls in one list, newest first, without fanning out one
request per number. Each row carries `channel` and `accountId` so you can fetch
details and recordings from the matching channel-specific endpoint.
Private beta: returns 403 unless your account is enrolled.
- name: Voice
description: |
Regular phone (PSTN) calling on your numbers. Enable voice on a number and route
inbound calls to your own AI voice agent (Vapi/Retell), a phone, or a SIP endpoint,
with voicemail, business-hours routing, IVR, recording, and transcription. Place
outbound calls, manage live ones (end, blind-transfer), resolve recordings, and
drive the browser softphone.
Private beta: returns 403 unless your account is enrolled.
- name: SMS
description: |
SMS/MMS on your numbers: enable SMS on a number, send messages, validate recipient
numbers, export STOP opt-outs, and complete the US carrier registration (10DLC or
toll-free) required before US traffic delivers.
Private beta: returns 404 unless your account is enrolled.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: API key authentication - use your Zernio API key as a Bearer token
connectToken:
type: apiKey
in: header
name: X-Connect-Token
description: |
Short-lived connect token for API users during OAuth flows.
Automatically generated when initiating OAuth without a browser session.
Valid for 15 minutes. Used to authenticate Facebook page selection API calls.
parameters:
PageParam:
name: page
in: query
description: Page number (1-based)
schema: { type: integer, minimum: 1, default: 1 }
IdempotencyKeyHeader:
name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes retries
safe. Same key + same body replays the original response; same key +
different body → 422; key still processing → 409.
headers:
BackfillRetryAfter:
description: Recommended delay before retrying, in seconds.
schema: { type: integer, example: 60 }
responses:
IdempotencyKeyInFlight:
description: Same Idempotency-Key still processing; retry after a short backoff
IdempotencyKeyReused:
description: Idempotency-Key reused with a different body
BadRequest:
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
Unauthorized:
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Unauthorized
NotFound:
description: Resource not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Not found
ResourceGroupForbidden:
description: >-
The API key is a restricted key (zrk_ prefix) and may not perform this
operation. Three cases. (1) The operation's resource group (see the
operation's x-resource-group) is disabled on the key: fix it by
creating a key with the group enabled in the dashboard API keys tab
and revoking the old one. (2) The operation is admin-plane
(x-resource-group admin-plane: API keys, invites, connected apps,
member identity), which is never grantable to restricted keys; the
error reads "Restricted API keys cannot manage API keys, invites, or
member identity." and the fix is a full-access key or the dashboard,
never a new restricted key. (3) On webhook subscription writes,
delivery-log reads and replays, a named event maps to a resource group
the key does not hold, so a restricted key can never create or edit a
subscription broader than itself (a no-messages key cannot subscribe
to, test-fire, redeliver or read logs for message.* events).
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab."
code:
type: string
enum: [insufficient_permissions, unclassified_resource]
required_group:
type: string
description: 'The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials.'
enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
RateLimited:
description: |
The connected account's upstream platform quota is exhausted.
Reddit rate-limits per connected Reddit user (1000 requests per
10-minute window), and that budget is shared by every operation using
that account. Retry after the window resets rather than retrying
immediately; repeated calls while exhausted do not succeed and keep the
budget spent.
headers:
Retry-After:
description: 'Seconds remaining until the upstream quota resets.'
schema: { type: integer }
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
PaymentRequired:
description: |
Payment method or enterprise contract required. The authenticated
account hit a billing gate before the connection could proceed.
Three reasons:
- `free_tier_exceeded`: the team has connected more accounts
than the free tier allows. Add a payment method on the
dashboard to continue (the user will be billed per
additional connected account).
- `twitter_passthrough`: connecting an X (Twitter) account
requires a card on file from day one because X API calls
incur real per-call pass-through costs. Applies to the 1st
X account, not just the 3rd+.
- `enterprise_required`: the team is on an enterprise
contract with a negotiated connected-account cap and has
reached it. Self-service teams have NO connection cap (the
$1/account rate continues at any scale), so this reason can
only fire for teams whose contract sets an explicit limit.
`dashboard_url` deep-links to the enterprise contact page
rather than the billing tab. The end-user already has a
card on file; this gate is about contract terms, not card
collection.
SDK consumers should switch on `reason` to render the right
prompt. For `free_tier_exceeded` and `twitter_passthrough`,
redirect the end-user to `dashboard_url` to add a payment method
via Zernio's hosted Stripe Setup Checkout. For
`enterprise_required`, redirect to `dashboard_url` (the
enterprise contact form) to adjust the contract's limit.
content:
application/json:
schema:
type: object
required: [error, code, reason]
properties:
error:
type: string
description: Human-readable error message suitable for end-user display.
example: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
code:
type: string
enum: [PAYMENT_REQUIRED]
description: Machine-readable error code. Stable across versions.
reason:
type: string
enum: [free_tier_exceeded, twitter_passthrough, enterprise_required]
description: Discriminator for which gate fired.
documentation_url:
type: string
format: uri
description: Link to the relevant documentation page.
example: https://docs.zernio.com/billing/payment-method-required
dashboard_url:
type: string
format: uri
description: |
Deep-link to send the end-user to. For
`free_tier_exceeded` and `twitter_passthrough` this is
the Zernio billing tab. For `enterprise_required` this
is the Zernio enterprise contact page.
example: https://zernio.com/dashboard?tab=billing
details:
type: object
description: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`.
properties:
free_tier_account_limit:
type: integer
description: How many accounts the free tier allows. Only set when reason=free_tier_exceeded.
example: 2
current_account_count:
type: integer
description: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required.
example: 5
has_payment_method:
type: boolean
description: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough.
effective_account_limit:
type: integer
description: |
The negotiated connected-account cap from the
team's enterprise contract. Self-service teams
have no cap and never receive this reason. Only
set when reason=enterprise_required.
example: 2000
examples:
freeTierExceeded:
summary: Free tier exceeded (no card on file)
value:
error: 'Add a payment method to connect more than 2 accounts.'
code: PAYMENT_REQUIRED
reason: free_tier_exceeded
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/dashboard?tab=billing
details:
free_tier_account_limit: 2
current_account_count: 3
has_payment_method: false
twitterPassthrough:
summary: Connecting first X account without a card
value:
error: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
code: PAYMENT_REQUIRED
reason: twitter_passthrough
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/dashboard?tab=billing
details:
has_payment_method: false
enterpriseRequired:
summary: Team reached the negotiated cap on its enterprise contract
value:
error: 'You have 2000 connected accounts, which reaches the 2,000-account limit on your contract. Contact us to raise it.'
code: PAYMENT_REQUIRED
reason: enterprise_required
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/enterprise
details:
effective_account_limit: 2000
current_account_count: 2000
schemas:
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` (mutually exclusive).
- Multi-creative: a non-empty `creatives[]` array. Top-level
`headline` / `body` / `imageUrl` / `video` must NOT be set
on this shape.
The route enforces this at the Zod boundary; OpenAPI's
`required` cannot express the OR cleanly.
properties:
accountId:
type: string
minLength: 1
description: Facebook or Instagram SocialAccount ID.
adAccountId:
type: string
minLength: 1
description: Meta ad account ID, e.g. `act_123456789`.
name:
type: string
minLength: 1
description: |
Ad display name. Used to derive campaign / ad set names.
On the multi-creative shape, each ad's Meta name gets a
" #N" suffix (1-indexed) so Ads Manager shows them as a
numbered batch.
headline:
type: string
minLength: 1
maxLength: 255
description: |
Single-creative shape only. Mutually exclusive with
`creatives[]`.
body:
type: string
minLength: 1
description: |
Primary text shown above the image / video. Single-creative
shape only. Mutually exclusive with `creatives[]`.
imageUrl:
type: string
format: uri
description: |
Image asset for single-creative shape. Mutually exclusive
with `video` and with `creatives[]`. Required on the
single-creative shape if `video` is not supplied.
video:
type: object
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 `imageUrl` is not 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
must supply its own headline, body, and exactly one of
`imageUrl` / `video`.
items:
type: object
required: [headline, body]
description: |
Each entry must also include exactly one of `imageUrl`
or `video`.
properties:
headline:
type: string
minLength: 1
maxLength: 255
body:
type: string
minLength: 1
description: Primary text shown above the image / video.
imageUrl:
type: string
format: uri
description: |
Image asset. Mutually exclusive with this entry's
`video`. Required if `video` is not supplied.
video:
type: object
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 `imageUrl` is not 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 }
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.
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`). 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).
CommentAutomationAudience:
type: object
description: |
Who a comment automation answers. Instagram only - Meta exposes the follow
relationship on no other platform, and only for people who have MESSAGED the
account (a comment grants no consent). `whenUnknown` is therefore the important
setting: it decides what happens for a first-time commenter.
properties:
followerStatus:
type: string
enum: [any, follower, non_follower]
default: any
minFollowerCount:
type: integer
minimum: 0
description: 'Skip commenters with fewer followers than this. Omit for no size rule.'
whenUnknown:
type: string
enum: [send, skip, verify]
default: send
description: |
What to do when Instagram will not reveal the follow relationship.
* `send` (default) - deliver the DM anyway (fails open).
* `skip` - stay silent.
* `verify` - send `followGate.message` with a confirm button. Tapping it is a
message, which grants consent, so the re-check on the tap resolves and the
real DM (or `followGate.notFollowingMessage`) follows automatically.
CommentAutomationFollowGate:
type: object
description: 'Copy for the follow gate. Sensible defaults are used for any field left empty.'
properties:
message:
type: string
maxLength: 640
description: 'Confirmation DM sent when whenUnknown=verify.'
buttonLabel:
type: string
maxLength: 20
description: 'Confirm button label. Defaults to "I''m following".'
notFollowingMessage:
type: string
maxLength: 1000
description: 'Sent to a commenter we know does not follow (followerStatus=follower). Omit to stay silent on a keyword comment; a confirm tap always gets an answer.'
DmButton:
type: object
description: |
A single inline button rendered inside an auto-DM via Meta's button_template.
Up to 3 buttons per automation. `url` and `postback` work on Instagram and
Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes
the button_template text and must be 640 characters or less.
required: [type, title]
properties:
type: { type: string, enum: [url, postback, phone] }
title: { type: string, maxLength: 20, description: Button label (20 chars max) }
url: { type: string, format: uri, description: Target URL (required when type is url) }
payload: { type: string, description: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) }
phone: { type: string, description: "Phone number, e.g. +14155551234 (required when type is phone; Facebook only)" }
CommentAutomationTemplate:
type: object
description: |
A Meta generic template (product card) sent as the automation's first DM.
It REPLACES the plain `dmMessage` bubble: a Meta message carries one body
shape, and a comment gets exactly one private reply, so the card and the
text cannot both be delivered. Put your selling copy in `subtitle`.
Mutually exclusive with `buttons` (sending both is a 400). Works on both
the `comment` and `story_reply` triggers.
Up to 10 elements, rendered as a horizontally swipeable carousel.
Rendering confirmed on the Instagram and Messenger mobile apps.
required: [type, elements]
properties:
type: { type: string, enum: [generic] }
elements:
type: array
minItems: 1
maxItems: 10
items: { $ref: '#/components/schemas/CommentAutomationTemplateElement' }
CommentAutomationTemplateElement:
type: object
required: [title]
properties:
title: { type: string, maxLength: 80, description: 'Card headline (80 chars max). Also used as the Inbox preview for the sent DM.' }
subtitle: { type: string, maxLength: 80, description: 'Card description, e.g. the price or a short pitch (80 chars max).' }
imageUrl: { type: string, format: uri, description: 'Publicly reachable http(s) image rendered large above the card.' }
buttons:
type: array
maxItems: 3
description: 'Up to 3 card buttons. A generic template has NO phone button, on either platform. `url` buttons are click-tracked when linkTracking is on.'
items:
type: object
required: [type, title]
properties:
type: { type: string, enum: [url, postback] }
title: { type: string, maxLength: 20 }
url: { type: string, format: uri, description: 'Target URL (required when type is url)' }
payload: { type: string, description: 'Postback payload delivered via the messaging_postbacks webhook (required when type is postback)' }
WhatsAppTemplateButton:
type: object
required: [type]
properties:
type:
type: string
enum: [quick_reply, url, phone_number, otp, copy_code, flow, mpm, catalog]
text:
type: string
description: 'Visible button label. Required for all types except copy_code (whose label is fixed by WhatsApp) and otp (omit it and WhatsApp supplies its own label, localized to the template language; an English label on a non-English template is rejected).'
url:
type: string
format: uri
description: Required when type is URL
example:
description: "Example value(s). Accepts either a string or an array of strings. For URL buttons, an array of URL-suffix samples; for copy_code (marketing) buttons, the coupon code as a single string. Left untyped (string | string[]) on purpose: a typed oneOf of string-vs-array breaks several SDK code generators."
phone_number:
type: string
description: Required when type is phone_number
otp_type:
type: string
enum: [copy_code, one_tap, zero_tap]
description: Required when type is otp
autofill_text:
type: string
package_name:
type: string
signature_hash:
type: string
flow_id:
type: string
flow_name:
type: string
flow_json:
type: string
flow_action:
type: string
navigate_screen:
type: string
WhatsAppTemplateComponent:
oneOf:
- $ref: '#/components/schemas/WhatsAppHeaderComponent'
- $ref: '#/components/schemas/WhatsAppBodyComponent'
- $ref: '#/components/schemas/WhatsAppFooterComponent'
- $ref: '#/components/schemas/WhatsAppButtonsComponent'
- $ref: '#/components/schemas/WhatsAppCarouselComponent'
- $ref: '#/components/schemas/WhatsAppLimitedTimeOfferComponent'
discriminator:
propertyName: type
mapping:
header: '#/components/schemas/WhatsAppHeaderComponent'
body: '#/components/schemas/WhatsAppBodyComponent'
footer: '#/components/schemas/WhatsAppFooterComponent'
buttons: '#/components/schemas/WhatsAppButtonsComponent'
carousel: '#/components/schemas/WhatsAppCarouselComponent'
limited_time_offer: '#/components/schemas/WhatsAppLimitedTimeOfferComponent'
WhatsAppHeaderComponent:
type: object
required: [type, format]
properties:
type:
type: string
enum: [header]
format:
type: string
enum: [text, image, video, gif, document, location]
text:
type: string
description: Header text (may include {{1}} variable). Used when format is TEXT.
example:
type: object
properties:
header_text:
type: array
items: { type: string }
description: Sample values for header text variables
header_text_named_params:
type: array
items:
$ref: '#/components/schemas/WhatsAppNamedParamExample'
description: 'Sample values for NAMED header variables (templates using {{customer_name}}-style tokens with parameter_format: NAMED).'
header_handle:
type: array
minItems: 1
maxItems: 1
items:
type: string
format: uri
description: When the header format is a media type (image, video, gif, document), provide a public URL here. Zernio will download and upload it to WhatsApp on your behalf, replacing it with the internal file handle before creating the template.
WhatsAppBodyComponent:
type: object
required: [type, text]
properties:
type:
type: string
enum: [body]
text:
type: string
description: Body text with optional {{n}} variables
add_security_recommendation:
type: boolean
description: Add security recommendation text (authentication templates only)
example:
type: object
properties:
body_text:
type: array
items:
type: array
items: { type: string }
description: Sample values for body variables (array of arrays)
body_text_named_params:
type: array
items:
$ref: '#/components/schemas/WhatsAppNamedParamExample'
description: 'Sample values for NAMED body variables (templates using {{customer_name}}-style tokens with parameter_format: NAMED).'
WhatsAppNamedParamExample:
type: object
required: [param_name, example]
properties:
param_name:
type: string
pattern: '^[a-z0-9_]+$'
description: 'Variable name as it appears in the text, without braces (e.g. customer_name for {{customer_name}}).'
example:
type: string
description: Sample value for this variable.
WhatsAppFooterComponent:
type: object
required: [type]
properties:
type:
type: string
enum: [footer]
text:
type: string
description: Static footer text
code_expiration_minutes:
type: integer
minimum: 1
description: OTP code expiry in minutes (authentication templates only)
WhatsAppButtonsComponent:
type: object
required: [type, buttons]
properties:
type:
type: string
enum: [buttons]
buttons:
type: array
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateButton'
WhatsAppCarouselComponent:
type: object
required: [type, cards]
properties:
type:
type: string
enum: [carousel]
cards:
type: array
minItems: 2
maxItems: 10
description: '2-10 cards. Meta requires all cards to share the same component structure; a mismatch surfaces as a rejected_reason. MARKETING category only.'
items:
type: object
required: [components]
properties:
components:
type: array
minItems: 1
description: 'Per-card components. Each card carries its own media header + optional body + up to 2 buttons. Footer and nested carousel are not allowed inside cards.'
items:
$ref: '#/components/schemas/WhatsAppCarouselCardComponent'
WhatsAppCarouselCardComponent:
oneOf:
- $ref: '#/components/schemas/WhatsAppHeaderComponent'
- $ref: '#/components/schemas/WhatsAppBodyComponent'
- $ref: '#/components/schemas/WhatsAppButtonsComponent'
discriminator:
propertyName: type
mapping:
header: '#/components/schemas/WhatsAppHeaderComponent'
body: '#/components/schemas/WhatsAppBodyComponent'
buttons: '#/components/schemas/WhatsAppButtonsComponent'
WhatsAppLimitedTimeOfferComponent:
type: object
required: [type, limited_time_offer]
properties:
type:
type: string
enum: [limited_time_offer]
limited_time_offer:
type: object
required: [text]
properties:
text:
type: string
minLength: 1
maxLength: 16
description: 'Short offer label (<=16 chars). MARKETING only.'
has_expiration:
type: boolean
description: 'Toggles the countdown timer. The actual coupon code + expiry are bound at SEND time, not here.'
WhatsAppSandboxSession:
type: object
description: |
A per-user activation session against the shared WhatsApp sandbox number.
Transitions `pending → active` when the inbound webhook receives a reply
from the matching phone (the reply itself proves ownership).
required: [id, phoneE164, status, expiresAt]
properties:
id:
type: string
description: Session id. Use this to revoke via DELETE.
phoneE164:
type: string
description: Digits-only E.164 form (no +, spaces, or dashes).
example: "34688246216"
status:
type: string
enum: [pending, active]
description: |
`pending` until the phone replies to the activation template, then
`active`. Expired sessions are pruned by TTL and never appear in
list responses.
expiresAt:
type: string
format: date-time
description: |
UTC timestamp at which the session becomes invalid. Pending sessions
get a 24h window; activated sessions get 7 days.
activatedAt:
type: [string, "null"]
format: date-time
description: When the session transitioned `pending → active`, or null.
createdAt:
type: [string, "null"]
format: date-time
FoodMenuLabel:
type: object
required: [displayName]
properties:
displayName: { type: string, description: Display name of the item/section/menu }
description: { type: string, description: Optional description }
languageCode: { type: string, description: "BCP-47 language code (e.g. en, es)" }
Money:
type: object
required: [currencyCode, units]
properties:
currencyCode: { type: string, description: "ISO 4217 currency code (e.g. USD, EUR)" }
units: { type: string, description: Whole units of the amount }
nanos: { type: integer, description: Nano units (10^-9) of the amount }
FoodMenuItemAttributes:
type: object
properties:
price: { $ref: '#/components/schemas/Money' }
spiciness: { type: string, enum: [SPICINESS_UNSPECIFIED, MILD, MEDIUM, HOT], description: "Spiciness level (e.g. MILD, MEDIUM, HOT)" }
allergen:
type: array
items: { type: string }
description: "Allergens (e.g. DAIRY, GLUTEN, SHELLFISH)"
dietaryRestriction:
type: array
items: { type: string }
description: "Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE)"
servesNumPeople: { type: integer, minimum: 1, description: Number of people the item serves }
preparationMethods:
type: array
items: { type: string }
description: "Preparation methods (e.g. GRILLED, FRIED)"
mediaKeys:
type: array
items: { type: string }
description: Media references for item photos
FoodMenuItem:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
options:
type: array
items:
type: object
required: [labels, attributes]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
description: Item variants/options (e.g. sizes, preparations)
FoodMenuSection:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
items:
type: array
items: { $ref: '#/components/schemas/FoodMenuItem' }
FoodMenu:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
sections:
type: array
items: { $ref: '#/components/schemas/FoodMenuSection' }
cuisines:
type: array
items: { type: string }
description: "Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE)"
sourceUrl:
type: string
format: uri
description: URL of the original menu source
YouTubeDailyViewsResponse:
type: object
properties:
success:
type: boolean
example: true
videoId:
type: string
description: The YouTube video ID
durationSeconds:
type: [integer, "null"]
description: Video length in seconds (from YouTube contentDetails.duration)
dateRange:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
provisionalSince:
type: string
format: date
description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
totalViews:
type: integer
description: Sum of views across all days in the range
dailyViews:
type: array
items:
type: object
properties:
date:
type: string
format: date
views:
type: integer
estimatedMinutesWatched:
type: number
averageViewDuration:
type: number
description: Average view duration in seconds
averageViewPercentage:
type: number
description: Average percentage of the video watched per view. Can exceed 100 on Shorts (looping rewatches), so do not clamp it client-side.
subscribersGained:
type: integer
subscribersLost:
type: integer
likes:
type: integer
comments:
type: integer
shares:
type: integer
lastSyncedAt:
type: [string, "null"]
format: date-time
description: When the data was last synced from YouTube
scopeStatus:
type: object
properties:
hasAnalyticsScope:
type: boolean
YouTubeVideoRetentionResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio account ID for the YouTube account
videoId:
type: string
description: The YouTube video ID
title:
type: [string, "null"]
description: Video title
publishedAt:
type: [string, "null"]
format: date-time
description: When the video was published on YouTube
durationSeconds:
type: [integer, "null"]
description: Video length in seconds (from YouTube contentDetails.duration)
dateRange:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
provisionalSince:
type: string
format: date
description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
retentionCurve:
type: array
description: 'Up to 100 points covering the video timeline, aggregated over the date range. 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, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, 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, verification.approved, verification.failed]
description: Events subscribed to
isActive:
type: boolean
description: Whether webhook delivery is enabled
lastFiredAt:
type: string
format: date-time
description: Timestamp of last successful webhook delivery
failureCount:
type: integer
description: Consecutive delivery failures (resets on success, webhook disabled at 10)
customHeaders:
type: object
additionalProperties:
type: string
description: Custom headers included in webhook requests
disabledResourceGroups:
type: array
items:
type: string
enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
description: >-
Resource groups this subscription does not receive (opt-out
denylist, same vocabulary and same semantics as the field on API
keys). Absent or empty means the subscription receives every event
listed in `events`, which is how every subscription created before
this field existed behaves. An event whose group is listed here is
dropped before delivery even when it is still present in `events`,
and the same check runs on every replay path (test fire,
redelivery, dead-letter requeue). Editing the denylist applies to
every event emitted afterwards; events already queued when the edit
landed can still be delivered for up to five minutes after they
were enqueued.
WebhookLog:
type: object
description: A single webhook delivery attempt recorded by Zernio (30-day retention).
properties:
userId:
type: string
description: ID of the account owner the webhook belongs to
webhookId:
type: string
description: ID of the webhook configuration that produced this delivery
webhookName:
type: string
description: Name of the webhook configuration at delivery time
eventId:
type: string
description: Stable webhook event ID (correlates to the delivered payload)
event:
type: string
description: Event type that triggered the delivery (e.g. post.published)
url:
type: string
format: uri
description: Destination URL the webhook was delivered to
status:
type: string
enum: [success, failed]
description: Delivery outcome
statusCode:
type: integer
description: HTTP status code returned by the destination endpoint
requestPayload:
type: object
additionalProperties: true
description: The JSON payload sent to the destination endpoint
responseBody:
type: string
description: Response body returned by the destination endpoint
errorMessage:
type: string
description: Error message when delivery failed
attemptNumber:
type: integer
description: Delivery attempt number (increments on retries)
responseTime:
type: integer
description: Time taken by the destination endpoint to respond, in milliseconds
createdAt:
type: string
format: date-time
description: Timestamp the delivery was attempted
WebhookPayloadPost:
type: object
description: Webhook payload for post events
required: [id, event, post, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled]
post:
type: object
required: [id, content, status, scheduledFor, platforms]
properties:
id:
type: string
content:
type: string
status:
type: string
scheduledFor:
type: string
format: date-time
publishedAt:
type: string
format: date-time
platforms:
type: array
items:
type: object
required: [platform, status]
properties:
platform:
type: string
status:
type: string
accountId:
type: string
description: SocialAccount id this platform target published through. Use it to route events by connected account (e.g. separate staging vs production endpoints). A post can span multiple accounts.
platformPostId:
type: string
publishedUrl:
type: string
error:
type: string
metadata:
type: object
additionalProperties: true
description: The free-form `metadata` object supplied when the post was created, echoed back so you can map events onto your own records. Omitted when the post was created without it.
timestamp:
type: string
format: date-time
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). The
`post` envelope mirrors the shape of `WebhookPayloadPost` so
consumers can reuse rendering logic; the `platform` block
identifies which specific platform transitioned; the `account`
block identifies the connected social account behind that
platform-write.
required: [id, event, post, platform, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID.
event:
type: string
enum: [post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved]
post:
type: object
required: [id, content, status, scheduledFor, platforms]
properties:
id: { type: string }
content: { type: string }
status:
type: string
description: |
Post-level status AT FIRE TIME. May still be `publishing`
if other platforms haven't terminated; check this field
rather than assuming.
scheduledFor: { type: string, format: date-time }
publishedAt: { type: string, format: date-time }
platforms:
type: array
items:
type: object
required: [platform, status]
properties:
platform: { type: string }
status: { type: string }
accountId: { type: string, description: 'SocialAccount id this platform target published through. On post.platform.* events see also the top-level `account` block.' }
platformPostId: { type: string }
publishedUrl: { type: string }
error: { type: string }
metadata:
type: object
additionalProperties: true
description: The free-form `metadata` object supplied when the post was created, echoed back so you can map events onto your own records. Omitted when the post was created without it.
platform:
type: object
description: The specific platform that just transitioned to a terminal state.
required: [name, status]
properties:
name:
type: string
description: Platform name (e.g. `twitter`, `tiktok`, `instagram`).
status:
type: string
enum: [published, failed, deleted]
description: Terminal status this event fires on. Matches the event suffix.
platformPostId:
type: string
description: 'Platform-native post id. Present on `published` and `deleted`, absent on `failed`.'
publishedUrl:
type: string
description: 'Public URL to the platform-side post. Present on `published` (when the platform exposes one and it is not a draft) and on `deleted` (when one was recorded at publish time).'
error:
type: string
description: Error message from the platform. Present on `failed` only.
deletedAt:
type: string
format: date-time
description: 'When the platform-side deletion was detected by Zernio sync (ISO 8601). Present only on `post.platform.deleted`.'
account:
type: object
description: The connected social account the platform-write went through.
required: [accountId, platform, username]
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
timestamp:
type: string
format: date-time
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.'
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.'
WebhookPayloadComment:
type: object
description: Webhook payload for comment received events (Instagram, Facebook, Twitter/X, YouTube, LinkedIn, Bluesky, Reddit)
required: [id, event, comment, post, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [comment.received]
comment:
type: object
required: [id, postId, platformPostId, platform, text, author, createdAt, isReply, parentCommentId]
properties:
id:
type: string
description: Platform comment ID
postId:
type: [string, "null"]
description: Internal post ID (null for posts not published through Zernio)
platformPostId:
type: string
description: Platform's post ID
platform:
type: string
enum: [instagram, facebook, twitter, youtube, linkedin, bluesky, reddit]
text:
type: string
description: Comment text content
author:
type: object
required: [id]
properties:
id:
type: string
description: Author's platform ID
username:
type: string
name:
type: string
picture:
type: [string, "null"]
isOwnAccount:
type: boolean
description: 'True when this comment was authored by the connected account itself (Meta re-delivers the account''s own replies as comments events). Populated on the Instagram and Facebook realtime webhooks only; absent means not evaluated, never "not the account".'
instagramProfile:
type: object
description: |
Instagram only, best-effort. Present ONLY for commenters who have
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: Social account ID
accountId:
type: string
description: Social account ID (same as id); canonical field for account filtering.
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
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: Social account ID (the facebook account owning the Page)
accountId:
type: string
description: Social account ID (same as id); canonical field for account filtering.
platform:
type: string
enum: [facebook]
timestamp:
type: string
format: date-time
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).
platform:
type: string
enum: [googlebusiness]
description: Platform the review originated on. Currently Google Business Profile only.
rating:
type: integer
minimum: 1
maximum: 5
description: Star rating the reviewer gave.
text:
type: string
description: Review text content. May be empty if the reviewer left only a rating.
reviewer:
type: object
required: [id, name, profileImage]
properties:
id:
type: [string, "null"]
description: Platform reviewer ID. Null when the platform does not expose it (common on Google Business anonymous reviews).
name:
type: string
profileImage:
type: [string, "null"]
createdAt:
type: string
format: date-time
hasReply:
type: boolean
description: Whether the connected account has replied to this review.
reply:
type: object
description: Present when hasReply is true.
required: [text, createdAt]
properties:
text:
type: string
createdAt:
type: string
format: date-time
WebhookPayloadReviewNew:
type: object
description: Webhook payload for the review.new event (new review posted on a connected account).
required: [id, event, review, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [review.new]
review:
$ref: '#/components/schemas/ReviewWebhookReview'
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
accountId:
type: string
description: Social account ID (same as id); canonical field for account filtering.
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
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 added (via the API or directly on the
platform). Same shape as review.new. When a reply is present, review.hasReply
is true and review.reply is populated.
required: [id, event, review, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [review.updated]
review:
$ref: '#/components/schemas/ReviewWebhookReview'
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
accountId:
type: string
description: Social account ID (same as id); canonical field for account filtering.
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
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 social account ID the post belongs to.
url:
type: [string, "null"]
description: Direct URL to the post on the platform, when available.
content:
type: string
description: Post text. May be empty.
mediaType:
type: string
description: One of image, video, gif, document, text, carousel.
mediaItems:
type: array
items:
$ref: '#/components/schemas/ExternalPostMediaItem'
thumbnailUrl:
type: [string, "null"]
publishedAt:
type: string
format: date-time
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: Social 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 — 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 — this field is omitted for them. Match by `businessScopedUserId`
instead. See `docs/whatsapp-bsuid-migration.md`.
businessScopedUserId:
type: string
description: |
WhatsApp only. Business-scoped user ID (BSUID) — Meta's canonical
identifier for a WhatsApp user within your business. Present when
Meta includes it in the inbound payload (rollout in progress since
early April 2026). **Recommended primary identity anchor** going
forward; fall back to `phoneNumber` only when this field is absent.
parentBusinessScopedUserId:
type: string
description: |
WhatsApp only. Parent BSUID for businesses with linked business
portfolios. Omitted for standalone portfolios.
whatsappUsername:
type: string
description: |
WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
stable identifier — users can change it. Useful for display, not
recommended as an identity anchor.
instagramProfile:
type: object
description: Instagram profile data. Only present for Instagram conversations.
properties:
isFollower: { type: [boolean, "null"] }
isFollowing: { type: [boolean, "null"] }
followerCount: { type: [integer, "null"] }
isVerified: { type: [boolean, "null"] }
sentAt:
type: string
format: date-time
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.
InboxWebhookAccount:
type: object
description: The account context included in inbox webhook payloads.
required: [id, platform, username]
properties:
id:
type: string
description: Social account ID
accountId:
type: string
description: Social account ID (same value as id). Canonical field so consumers can filter every webhook event on one field (e.g. route staging vs production by account). id is kept for backward compatibility.
profileId:
type: string
description: Zernio profile (workspace) ID this account belongs to. Use it to route or filter inbox webhooks by workspace. This is the profile ID only, not its name (resolve the name via the API with this ID). Optional; omitted on the shared WhatsApp sandbox account and when the account has no resolvable profile.
platform: { type: string }
username: { type: string }
displayName: { type: string }
# ────────────────────────────────────────────────────────────────────────
WebhookPayloadReaction:
type: object
description: 'Webhook payload for reaction received events (WhatsApp, Telegram, Slack, Instagram, Facebook Messenger)'
required: [id, event, reaction, conversation, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [reaction.received]
reaction:
type: object
required: [emoji, action, platformMessageId, sender, reactedAt]
properties:
emoji:
type: string
description: |
The emoji reacted with. May be an empty string when `action` is
`removed` on WhatsApp (Meta does not report which emoji was removed).
action:
type: string
enum: [added, removed]
messageId:
type: string
description: Internal Zernio message ID of the reacted-to message, when resolvable from the platform ID.
platformMessageId:
type: string
description: Platform-native ID of the reacted-to message (e.g. WhatsApp wamid).
sender:
type: object
required: [id]
description: 'Whoever added or removed the reaction. Usually the participant, but on 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)
url:
type: string
description: |
Where to fetch the attachment. **The contract differs by platform.**
- **WhatsApp**: points at `GET /v1/whatsapp/media/{mediaId}`, an
authenticated Zernio endpoint. You MUST send
`Authorization: Bearer <your API key>`; fetching it without that
header returns `401`. Download and store the bytes when this
webhook arrives: Meta drops inbound media after a limited
retention window, after which the endpoint answers `400`
permanently and the media is unrecoverable.
- **Instagram / Facebook / Telegram**: a direct platform CDN link
that needs no authentication and expires on the platform's own
schedule.
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
description: |
Sender's platform identifier. For WhatsApp this is the phone
number (without leading `+`) when available, otherwise the
`businessScopedUserId`.
contactId:
type: string
description: Zernio CRM Contact id for this sender, when one exists (omitted for outgoing/business sender).
name:
type: string
username:
type: string
picture:
type: string
phoneNumber:
type: [string, "null"]
description: |
WhatsApp only. Sender's phone number in E.164 format (with leading `+`).
**Nullable during the BSUID rollout (April 2026+).** WhatsApp
users who adopt a username can message businesses without
exposing a phone number — this field is omitted for them.
Match by `businessScopedUserId` instead. See
`docs/whatsapp-bsuid-migration.md`.
businessScopedUserId:
type: string
description: |
WhatsApp only. Business-scoped user ID (BSUID) — Meta's canonical
identifier for a WhatsApp user within your business. Present
when Meta includes it in the inbound payload (rollout in
progress since early April 2026). **Recommended primary identity
anchor** going forward; fall back to `phoneNumber` only when
this field is absent.
parentBusinessScopedUserId:
type: string
description: |
WhatsApp only. Parent BSUID for businesses with linked business
portfolios. Omitted for standalone portfolios.
whatsappUsername:
type: string
description: |
WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
stable identifier — users can change it. Useful for display,
not recommended as an identity anchor.
instagramProfile:
type: object
description: Instagram profile data for the sender. Only present for Instagram conversations.
properties:
isFollower:
type: [boolean, "null"]
description: Whether the sender follows your Instagram business account
isFollowing:
type: [boolean, "null"]
description: Whether your Instagram business account follows the sender
followerCount:
type: [integer, "null"]
description: The sender's follower count on Instagram
isVerified:
type: [boolean, "null"]
description: Whether the sender is a verified Instagram user
sentAt:
type: string
format: date-time
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, or a quote-reply to an earlier message)'
properties:
quotedMessageId:
type: string
description: |
platformMessageId of the message this one is a quote-reply to.
WhatsApp (`context.id`), Instagram and Facebook Messenger
(`reply_to.mid`). Outgoing quote-replies carry the same field on
`message.sent`; see WebhookPayloadMessageSent.metadata.
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.
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, just 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)
url:
type: string
description: |
Where to fetch the attachment. For outgoing messages this is the
media URL as sent, so for WhatsApp it is the URL you supplied when
publishing (WhatsApp sends media by link), not a Zernio endpoint,
and it needs no Zernio credentials. Contrast the inbound direction:
`message.received` attachment URLs on WhatsApp point at the
authenticated `GET /v1/whatsapp/media/{mediaId}`.
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
contactId:
type: string
description: '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
username:
type: string
picture:
type: string
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]
description: 'WhatsApp send origin. whatsapp_business_app when sent from the WhatsApp Business phone app on a Coexistence number; cloud_api when sent through Zernio (dashboard, API, or broadcasts). Absent on non-WhatsApp platforms. 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. The key is present only when the send carried some context, and absent otherwise: it is never null and never an empty object.'
properties:
quotedMessageId:
type: string
description: |
platformMessageId of the message this send is a quote-reply to.
Set when the reply was sent through Zernio with `replyTo` on the
inbox send API (WhatsApp and Telegram), and when the operator
replied from the native WhatsApp Business, Instagram or Messenger
app. WhatsApp API sends carry it on the event fired from the
delivery status, so it arrives on the same `message.sent` as any
other WhatsApp send.
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. 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 }
explanation:
type: [string, "null"]
description: |
Plain-language translation of `code` (e.g. for 131026, that the
recipient has likely opted out of marketing messages while utility
templates are unaffected). Null for unmapped codes; fall back to
title/message.
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp:
type: string
format: date-time
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, Twitter, Reddit,
Bluesky, SMS). Platform-agnostic — one subscription covers every DM platform.
required: [id, event, conversation, account, startedAt, timestamp]
properties:
id: { type: string, description: Stable webhook event ID }
event:
type: string
enum: [conversation.started]
conversation:
type: object
required: [id, platform, platformConversationId, participantName, status]
properties:
id: { type: string, description: Internal conversation ID }
platform:
type: string
enum: [instagram, facebook, telegram, whatsapp, twitter, reddit, bluesky, sms, slack]
platformConversationId: { type: string }
participantId: { type: string, description: "Contact's platform identifier (IGSID, PSID, wa_id, etc.)" }
participantName: { type: string }
participantUsername: { type: string, description: Contact's handle when the platform exposes one }
participantPicture: { type: string }
status:
type: string
enum: [active, archived]
contactId:
type: string
description: |
Zernio CRM Contact ID for the participant, when one exists. Resolved by
joining `participantId` to the ContactChannel collection (same join
used by message.*, reaction.received, and call.* webhooks). Best-effort:
omitted when no channel matches or `participantId` is absent. Lets
integrators seed the CRM straight from `conversation.started` without
waiting for the first `message.*` event.
account:
$ref: '#/components/schemas/InboxWebhookAccount'
startedAt:
type: string
format: date-time
description: When the conversation document was created.
timestamp:
type: string
format: date-time
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 social account. The unified /v1/calls/{id} detail + recording endpoints work for any channel; the channel-specific endpoints remain for account-scoped access." }
conversationId: { type: [string, "null"], description: "Inbox conversation with the counterparty, when one exists." }
contactId: { type: [string, "null"], description: "CRM Contact for the counterparty, when resolved." }
channel: { type: string, enum: [whatsapp, pstn] }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string, description: "Caller number (E.164)." }
to: { type: string, description: "Callee number (E.164)." }
forwardTo: { type: [string, "null"], description: "Destination the call was routed to (tel:/sip:/wss:), snapshotted at routing time." }
greeting: { type: [string, "null"], description: "Outbound PSTN only. Message spoken to the callee on answer, before the bridge." }
status: { type: string, enum: [ringing, answered, ended, failed] }
isVoicemail: { type: boolean, description: True when an inbound call went to voicemail. }
amd: { type: boolean, description: Outbound answering-machine detection was requested for this call. }
answeredMachine: { type: [boolean, "null"], description: "With `amd`, whether a machine (vs a human) answered." }
forwardCallerId: { type: string, enum: [business, caller], description: Caller ID presented on the forwarded leg. }
recordingEnabled: { type: boolean, description: "Effective flag for THIS call (number default + per-call override, resolved at create time)." }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
startedAt: { type: string, format: date-time }
answeredAt: { type: [string, "null"], format: date-time }
endedAt: { type: [string, "null"], format: date-time }
transferredAt: { type: [string, "null"], format: date-time, description: "When the call was blind-transferred (POST /v1/voice/calls/{id}/transfer)." }
durationSeconds: { type: integer }
endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
hangupCause: { type: [string, "null"], description: "Raw carrier hangup cause behind endReason (e.g. normal_clearing, not_found, time_limit) — the actual motive when endReason is a coarse bucket." }
sipHangupCause: { type: [string, "null"], description: "SIP response code that ended the call, when SIP-signalled (e.g. '403', '488'). The real failure reason for SIP legs." }
callErrors:
type: array
description: Per-call failure log (dial failed, bridge failed, recording error).
items:
type: object
properties:
code: { type: integer }
message: { type: string }
recordingUrl: { type: [string, "null"], description: "May be expired. Resolve a fresh playable URL via GET /v1/calls/{id}/recording (any channel)." }
lastTranscriptSnippet: { type: [string, "null"], description: "Most recent transcript segment, for list previews." }
transcript:
type: array
description: Full transcript segments (detail endpoint only; omitted from lists).
items:
type: object
properties:
text: { type: string }
confidence: { type: number }
at: { type: string, format: date-time }
billing:
type: object
properties:
metaMinutes: { type: number }
telnyxSeconds: { type: number }
transcriptionSeconds: { type: number }
transcriptionCostUSD: { type: number }
metaCostUSD: { type: number, description: 'WhatsApp channel only. Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Amount Zernio bills you = telephony leg + recording + transcription (excludes any Meta portion).' }
totalCostUSD: { type: number, description: 'Full cost incl. any Meta portion you pay directly. Display only.' }
currency: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
WebhookPayloadCallReceived:
type: object
description: |
Webhook payload for the `call.received` event. Fires for both
inbound (UIC) and outbound (BIC) calls; branch on
`call.direction` to tell them apart.
required: [id, event, call, account, timestamp]
properties:
id: { type: string, description: Stable webhook event ID }
event:
type: string
enum: [call.received]
call:
type: object
properties:
id: { type: string, description: Internal Zernio Call doc id }
metaCallId: { type: [string, "null"], description: "Meta wacid.* call id when known" }
accountId: { type: string }
phoneNumberId: { type: string, description: "Meta phone_number_id" }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string, description: "Consumer wa_id / E.164" }
to: { type: string, description: "Business number (E.164)" }
forwardTo: { type: string, description: "Destination snapshot at routing time" }
contactId: { type: string }
conversationId: { type: string }
startedAt: { type: string, format: date-time }
account: { $ref: '#/components/schemas/InboxWebhookAccount' }
timestamp:
type: string
format: date-time
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." }
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` — Meta
may add new values.
example: ACTIVE
error:
type: object
description: |
Optional. Present on most `WITH_ISSUES` events, carrying the
platform's error diagnostics. May be absent on some `WITH_ISSUES`
events (Meta does not always include diagnostics). Always absent
for any other `status.raw` value. Always null-check before reading.
required: [code]
properties:
code:
type: string
description: |
Platform-native error code, forwarded verbatim. For Meta this
is `error_code` as a string. Use as the stable discriminator —
`summary` and `message` are localized.
example: "2643001"
summary:
type: string
description: |
Short human-readable summary (Meta `error_summary`). Localized
to the ad-account owner's Meta locale — display only, do not
match on it.
example: Ad Processing Error
message:
type: string
description: |
Full human-readable error message (Meta `error_message`).
Localized — display only.
timestamp:
type: string
format: date-time
description: '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/Twitter (media-level
restriction), and LinkedIn (organization pages only, min 300 targeted followers). Ignored on
unsupported platforms. Stories (Facebook, Instagram) do not support geo-restriction.
properties:
countries:
type: array
minItems: 1
maxItems: 25
items:
type: string
pattern: '^[A-Z]{2}$'
example: US
description: >
ISO 3166-1 alpha-2 country codes (uppercase). Only users in these countries can see the post.
Maximum 25 countries per post. Example: ["US", "CA", "GB", "ES"].
example: ["US", "CA", "GB"]
required:
- countries
MediaItem:
type: object
description: Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed).
properties:
type:
type: string
enum: [image, video, gif, document]
url:
type: string
format: uri
title:
type: string
description: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename.
altText:
type: string
description: "Accessibility alternative text for an image, applied on every platform that supports it: Instagram (feed images only, not Reels/Stories), Facebook, Threads, X/Twitter (max 1000 chars), LinkedIn, Bluesky, and Pinterest (max 500 chars). Ignored on platforms without alt-text support (TikTok, YouTube, Snapchat, Telegram, Reddit, Google Business, WhatsApp) and on video items where the platform does not accept it. Set once per image; the same value is sent to each selected platform."
filename:
type: string
size:
type: integer
description: Optional file size in bytes
mimeType:
type: string
description: Optional MIME type (e.g. image/jpeg, video/mp4)
thumbnail:
type: string
format: uri
description: 'Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, regular video uploads, and LinkedIn video posts. Max 10MB, JPG/PNG recommended.'
instagramThumbnail:
type: string
format: uri
description: "Custom cover image URL for Instagram Reels. Can also be set via platformSpecificData.instagramThumbnail or platformSpecificData.reelCover. Resolution order: this field > platformSpecificData.instagramThumbnail > platformSpecificData.reelCover > platformSpecificData.thumbnailUrl (legacy)."
tiktokProcessed:
type: boolean
description: Internal flag indicating the image was resized for TikTok
ExternalPostMediaItem:
type: object
description: >
A media item on a native (external/synced) post, as carried by post.external.*
webhook payloads. Distinct from the richer MediaItem used for Zernio-authored
posts: external items are always already-published 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, publishing, published, failed"
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 (shared daily API quota empty, resumes at the platform's reset), system_error (Zernio infra), unknown"
errorSource:
type: string
enum: [user, platform, system]
description: "Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare)"
Post:
type: object
properties:
_id: { type: string }
userId:
oneOf:
- type: string
- $ref: '#/components/schemas/User'
title:
type: string
description: |
YouTube: title must be ≤ 100 characters.
content: { type: string }
mediaItems:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
platforms:
type: array
items: { $ref: '#/components/schemas/PlatformTarget' }
scheduledFor: { type: string, format: date-time }
timezone: { type: string }
status: { type: string, enum: [draft, scheduled, publishing, published, failed, partial] }
tags:
type: array
description: "YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed."
items: { type: string }
hashtags:
type: array
items: { type: string }
mentions:
type: array
description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
items: { type: string }
visibility: { type: string, enum: [public, private, unlisted] }
metadata:
type: object
additionalProperties: true
recycling:
$ref: '#/components/schemas/RecyclingState'
recycledFromPostId:
type: string
description: ID of the original post if this post was created via recycling
queuedFromProfile:
type: string
description: Profile ID if the post was scheduled via the queue
queueId:
type: string
description: Queue ID if the post was scheduled via a specific queue
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
RecyclingConfig:
type: object
description: |
Configure automatic post recycling (reposting at regular intervals).
After the post is published, the system creates new scheduled copies at the
specified interval until expiration conditions are met. Supports weekly or
monthly intervals. Maximum 10 active recycling posts per account.
YouTube and TikTok platforms are excluded from recycling.
Content variations are recommended for Twitter and Pinterest to avoid duplicate flags.
properties:
enabled:
type: boolean
default: true
description: Set to false to disable recycling on this post
gap:
type: integer
minimum: 1
description: Number of interval units between each repost. Required when enabling recycling.
example: 2
gapFreq:
type: string
enum: [week, month]
default: month
description: Interval unit for the gap. Defaults to 'month'.
startDate:
type: string
format: date-time
description: When to start the recycling cycle. Defaults to the post's scheduledFor date.
expireCount:
type: [integer, "null"]
minimum: 1
description: Stop recycling after this many copies have been created. Send null on update to clear this limit.
example: 5
expireDate:
type: [string, "null"]
format: date-time
description: Stop recycling after this date, regardless of count. Send null on update to clear this limit.
contentVariations:
type: array
items:
type: string
maxItems: 20
description: |
Array of content variations for recycled copies. On each recycle, the next
variation is used in round-robin order. Recommended for Twitter and Pinterest
to avoid duplicate content flags. If omitted, the original post content is
used for all recycled copies. Send an empty array [] to clear existing
variations. Must have 2+ entries when setting variations. Platform-level
customContent still overrides the base content per platform.
RecyclingState:
type: object
description: Current recycling configuration and state on a post
properties:
enabled:
type: boolean
description: Whether recycling is currently active
gap:
type: integer
description: Number of interval units between reposts
gapFreq:
type: string
enum: [week, month]
description: Interval unit (week or month)
startDate: { type: string, format: date-time }
expireCount: { type: integer }
expireDate: { type: string, format: date-time }
contentVariations:
type: array
items:
type: string
description: Content variations for recycled copies (if configured)
contentVariationIndex:
type: integer
description: Current position in the content variations rotation (read-only)
recycleCount:
type: integer
description: How many recycled copies have been created so far (read-only)
nextRecycleAt:
type: string
format: date-time
description: When the next recycled copy will be created (read-only)
lastRecycledAt:
type: string
format: date-time
description: When the last recycled copy was created (read-only)
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: Enable long video uploads (over 140 seconds) using amplify_video media category. Requires the connected X account to have an active X Premium subscription. When true, videos are uploaded with the amplify_video category which supports longer durations (up to 10 minutes via API). When false or omitted, the standard tweet_video category is used (140 second limit). Note that not all Premium accounts have API long-video access, as X may require separate allowlisting.
geoRestriction:
$ref: '#/components/schemas/GeoRestriction'
paidPartnership:
type: boolean
default: false
description: When true, the post is labeled by X as a paid partnership / paid promotion. For threads, applies to the root tweet only. Field availability may depend on your X API access tier.
madeWithAi:
type: boolean
default: false
description: When true, the post is labeled by X as containing AI-generated media. Per X, this label is for AI-generated media, not AI-written text. For threads, applies to the root tweet only.
sensitiveMedia:
type: object
description: Marks attached media with a sensitive-content warning. Applied to every media item in the post (requires media; ignored for text-only tweets). At least one flag must be true for the warning to be sent.
properties:
adultContent:
type: boolean
description: Content contains adult material.
graphicViolence:
type: boolean
description: Content depicts graphic violence.
other:
type: boolean
description: Content has other sensitive characteristics.
description: >
X-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 single vertical video (9:16, 3-60s). Geo-restriction is a hard visibility
restriction: users outside the specified countries cannot see the post. Not supported for stories.
Draft, carousel, and colored-background text options live under facebookSettings, see FacebookSettings.
FacebookSettings:
type: object
description: >
Facebook options that must be nested under platformSpecificData.facebookSettings, or sent
at the request root as facebookSettings. The remaining Facebook options sit directly on
platformSpecificData, see FacebookPlatformData.
properties:
draft:
type: boolean
description: When true, creates the post as a draft in Facebook Publishing Tools instead of publishing immediately. Supported for feed posts (text, link, image, video) and reels. Not supported for stories. Drafts expire after ~30 days.
default: false
carouselCards:
type: array
minItems: 2
maxItems: 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.
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. Note that 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." }
LinkedInAdsPlatformData:
type: object
additionalProperties: false
properties:
costType:
type: string
enum: [CPM, CPC, CPV]
description: |
Campaign cost model (billing event). Defaults to `CPM`. Required when
`unitCost` is set so the manual bid applies to an explicit cost model.
unitCost:
type: number
description: |
Manual bid in WHOLE account-currency units (e.g. 2.5 = $2.50). Requires
`costType`. Omit for LinkedIn's automated (max delivery) bidding.
LinkedIn enforces its own per-audience min/max bid bounds.
optimizationTargetType:
type: string
description: |
Campaign `optimizationTargetType` (e.g. `MAX_CLICK`, `TARGET_COST_PER_CLICK`,
`MAX_IMPRESSION`). Forwarded verbatim, LinkedIn validates compatibility with
the objective and `costType`. Omit for the objective-derived default:
`awareness` gets `MAX_IMPRESSION`, `video_views` gets `MAX_VIDEO_VIEW`, and
every other goal gets `MAX_CLICK`. `lead_generation` and `conversions` also
get `MAX_CLICK`, because `MAX_LEAD` and `MAX_CONVERSION` need a lead gen form
or a conversion rule that neither creation flow attaches. The default applies
only to `SPONSORED_UPDATES` campaigns (every boost, and the image, video and
carousel standalone ads), never to the `TEXT_AD`, `DYNAMIC` and
`SPONSORED_INMAILS` campaigns the other creative formats produce. It is also
skipped when `unitCost` or a non-`CPM` `costType` is set, since those select
manual bidding and the bid is then yours to choose.
creativeSelection:
type: string
enum: [OPTIMIZED, ROUND_ROBIN]
description: How LinkedIn rotates creatives within the campaign. Defaults to `OPTIMIZED`.
audienceExpansionEnabled:
type: boolean
description: Enable LinkedIn audience expansion. Defaults to false.
offsiteDeliveryEnabled:
type: boolean
description: Deliver on the LinkedIn Audience Network. Defaults to false.
connectedTelevisionOnly:
type: boolean
description: Restrict delivery to Connected TV inventory.
carousel:
type: object
additionalProperties: false
required: [cards]
description: |
POST /v1/ads/create only. Carousel ad with 2-10 image cards.
Mutually exclusive with the other creative sources.
properties:
cards:
type: array
minItems: 2
maxItems: 10
items:
type: object
additionalProperties: false
required: [imageUrl]
properties:
imageUrl: { type: string, format: uri }
headline:
type: string
description: Card title. Falls back to the ad-level headline.
landingUrl:
type: string
format: uri
description: |
Per-card click destination. LinkedIn requires one on every
card; the ad-level `linkUrl` backfills cards that omit it.
document:
type: object
additionalProperties: false
required: [url, title]
description: |
POST /v1/ads/create only. Document ad rendered as an in-feed viewer.
PDF, PPT or DOC up to 100MB. Mutually exclusive with the other
creative sources.
properties:
url: { type: string, format: uri }
title:
type: string
description: Document title.
spotlight:
type: object
additionalProperties: false
required: [headline, callToAction, landingUrl]
description: |
POST /v1/ads/create only. Dynamic Spotlight Ad personalized with the
viewer's profile photo. Supported goals: traffic, awareness. logoUrl
and organizationName default to the Company Page's; set them
explicitly if LinkedIn rejects the create with a 404. Mutually
exclusive with the other creative sources.
properties:
headline: { type: string }
description:
type: string
description: Mutually exclusive with backgroundImageUrl.
callToAction:
type: string
description: Button label text.
landingUrl: { type: string, format: uri }
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
backgroundImageUrl:
type: string
format: uri
description: Custom background. Replaces the description and the profile photo.
follower:
type: object
additionalProperties: false
required: [headline, description, callToAction]
description: |
POST /v1/ads/create only. Dynamic Follower Ad promoting the Company
Page. Supported goals: engagement, awareness. headline and
description take exactly one of preApproved or custom. Mutually
exclusive with the other creative sources.
properties:
headline:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example GROW_YOUR_BUSINESS_INSIGHTS.
custom:
type: string
description: Free text, reviewed by LinkedIn.
description:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example GET_LATEST_JOBS_AND_INDUSTRY_NEWS.
custom:
type: string
description: Free text, reviewed by LinkedIn.
callToAction:
type: string
enum: [VISIT_ORGANIZATION_COMPANY_PAGE, VISIT_ORGANIZATION_LIFE_PAGE, VISIT_ORGANIZATION_JOBS_PAGE, VISIT_ORGANIZATION_CAREERS_PAGE]
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
jobs:
type: object
additionalProperties: false
required: [headline, buttonLabel]
description: |
POST /v1/ads/create only. Dynamic Jobs Ad promoting your open roles,
personalized with the viewer's profile photo. Requires goal
job_applicants and a Company Page with active job postings.
headline and buttonLabel take exactly one of
preApproved or custom. logoUrl and organizationName default to the
Company Page's. Mutually exclusive with the other creative sources.
properties:
headline:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example MEMBER_READY_FOR_YOUR_DREAM_JOB.
custom:
type: string
description: Free text, reviewed by LinkedIn.
buttonLabel:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. One of SEE_MORE_JOBS, VIEW_MORE, CAREERS_AT_COMPANY.
custom:
type: string
description: Free text, reviewed by LinkedIn.
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
textAd:
type: object
additionalProperties: false
required: [headline, description, landingUrl]
description: |
POST /v1/ads/create only. Classic right-rail Text Ad. The copy lives
here; ad-level body and headline are not used. Mutually exclusive
with the other creative sources.
properties:
headline:
type: string
maxLength: 25
description:
type: string
maxLength: 75
landingUrl:
type: string
format: uri
imageUrl:
type: string
format: uri
description: Optional 100x100 image.
conversation:
type: object
additionalProperties: false
required: [subject, firstMessageId, messages]
description: |
POST /v1/ads/create only. Conversation Ad: a choose-your-path message
tree delivered to the member's LinkedIn inbox. Messages are flat
nodes wired by local ids; each button either opens a url or leads to
nextMessageId. Cycles, unknown ids and a missing firstMessageId
return a 400. LinkedIn does not deliver message ads to EU members.
Mutually exclusive with the other creative sources.
properties:
subject:
type: string
description: InMail subject shown in the inbox.
sender:
type: string
description: |
Person or organization URN. Defaults to the authoring Company
Page. The sender must be approved for the ad account first
(Campaign Manager > Manage message ad senders) or LinkedIn
rejects the create with SINMAIL_SENDER_NOT_APPROVED.
body:
type: string
description: Optional intro body (HTML allowed).
footer:
type: string
description: Terms shown at the bottom of the message.
headline:
type: string
description: Conversation headline. Defaults to the first message's first line.
firstMessageId: { type: string }
messages:
type: array
minItems: 1
maxItems: 25
items:
type: object
additionalProperties: false
required: [id, text]
properties:
id: { type: string }
text: { type: string, maxLength: 500 }
buttons:
type: array
minItems: 1
maxItems: 5
items:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string, maxLength: 35 }
nextMessageId:
type: string
description: Continues the conversation at this message. Exactly one of nextMessageId or url.
url:
type: string
format: uri
description: Opens this landing page. Exactly one of nextMessageId or url.
event:
type: object
additionalProperties: false
required: [urn]
description: |
POST /v1/ads/create only. Promotes an existing LinkedIn Event; no
headline needed. Mutually exclusive with the other creative sources.
properties:
urn:
type: string
pattern: '^urn:li:event:\d+$'
description: LinkedIn Event URN, urn:li:event:N.
thoughtLeader:
type: object
additionalProperties: false
required: [postUrn]
description: |
POST /v1/ads/create only. Sponsors an existing LinkedIn post
(a share or ugcPost authored by your organization's Company
Page) as the creative, keeping its commentary, author and
engagement. Unlike boostPost, which provisions its own
CampaignGroup + Campaign around the post, this variant
attaches the reference under the campaign /v1/ads/create
builds — same shape as every other format, so the caller can
pick bidding / targeting / schedule freely. No headline, body,
imageUrl or organization are needed; the referenced post
carries its own commentary and author. Mutually exclusive
with the other creative sources. Posts from personal profiles
(Thought Leader Ads) are NOT supported (see postUrn).
properties:
postUrn:
type: string
pattern: '^urn:li:(share|ugcPost):\d+$'
description: >
LinkedIn share or ugcPost URN, urn:li:share:N or
urn:li:ugcPost:N. Get it via "Copy link to post" on the
target LinkedIn post (the URL contains -share- for a share
or -ugcPost- for a ugcPost, then the numeric id).
The post must be authored by an organization (Company
Page). Member (personal profile) posts, i.e. Thought
Leader Ads proper, are rejected by LinkedIn's public
Marketing API regardless of sponsorship approval and of
post type (a LinkedIn limitation; their Campaign Manager
creates those through a private API). Referencing a
member post returns a 422 with a clear error.
description: >
LinkedIn-specific options for POST /v1/ads/boost and POST /v1/ads/create:
campaign bidding and delivery controls, plus the LinkedIn-only creative
formats on /v1/ads/create. Unknown keys are rejected.
PinterestPlatformData:
type: object
properties:
title:
type: string
maxLength: 100
description: Pin title. Defaults to first line of content or "Pin". Must be ≤ 100 characters.
boardId:
type: string
description: Target Pinterest board ID. If omitted, the first available board is used.
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 simply 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.
GoogleBusinessPlatformData:
type: object
properties:
locationId:
type: string
description: Target GBP location ID (e.g. "locations/123456789"). If omitted, uses the default location. Use GET /v1/accounts/{id}/gmb-locations to list locations.
languageCode:
type: string
description: BCP 47 language code (e.g. "en", "de", "es"). Auto-detected if omitted. Set explicitly for short or mixed-language posts.
example: "de"
topicType:
type: string
enum: [STANDARD, EVENT, OFFER]
default: STANDARD
description: "Post type. STANDARD is a regular update. EVENT requires the event object. OFFER requires the offer object. Defaults to STANDARD if omitted."
callToAction:
type: object
description: Optional call-to-action button displayed on the post
properties:
type:
type: string
enum: [LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL]
description: "Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL"
url:
type: string
format: uri
description: Destination URL for the CTA button (required when callToAction is provided)
required: [type, url]
event:
type: object
description: "Event details. Required when topicType is EVENT. Google returns 400 if omitted for EVENT posts."
properties:
title:
type: string
description: Event name (displayed as the event heading on Google Search and Maps)
example: "Grand Opening Weekend"
schedule:
type: object
description: "Event date/time range. Uses Google's date format (NOT ISO 8601)."
properties:
startDate:
type: object
description: "Event start date as { year, month, day }"
properties:
year:
type: integer
example: 2026
month:
type: integer
minimum: 1
maximum: 12
example: 5
day:
type: integer
minimum: 1
maximum: 31
example: 15
required: [year, month, day]
startTime:
type: object
description: "Optional start time as { hours, minutes } in 24h format"
properties:
hours:
type: integer
minimum: 0
maximum: 23
example: 9
minutes:
type: integer
minimum: 0
maximum: 59
example: 0
endDate:
type: object
description: "Event end date as { year, month, day }"
properties:
year:
type: integer
example: 2026
month:
type: integer
minimum: 1
maximum: 12
example: 5
day:
type: integer
minimum: 1
maximum: 31
example: 16
required: [year, month, day]
endTime:
type: object
description: "Optional end time as { hours, minutes } in 24h format"
properties:
hours:
type: integer
minimum: 0
maximum: 23
example: 17
minutes:
type: integer
minimum: 0
maximum: 59
example: 0
required: [startDate, endDate]
required: [title, schedule]
offer:
type: object
description: "Offer details. Required when topicType is OFFER. All fields are optional per Google's API, but at least one is recommended."
properties:
redeemOnlineUrl:
type: string
format: uri
description: URL where the offer can be redeemed online
termsConditions:
type: string
description: Terms and conditions for the offer
couponCode:
type: string
description: Coupon code for the offer
example: "SAVE20"
description: "Text and single image only (no videos). Supports STANDARD, EVENT, OFFER, and ALERT post types. Posts appear on GBP, Google Search, and Maps. Use locationId for multi-location posting. Schedule dates accept both ISO 8601 strings (e.g. '2026-04-15T09:00:00Z') and Google's native {year, month, day} objects."
TikTokPlatformData:
type: object
description: |
Photo carousels up to 35 images. Video titles up to 2200 chars, photo titles truncated to 90 chars.
privacyLevel must match creator_info options. Both camelCase and snake_case accepted.
Creator Inbox (draft mode): Set draft: true to send content to the TikTok Creator Inbox
instead of publishing immediately. The creator receives an inbox notification and completes
the post using TikTok's editing flow. This maps to TikTok's post_mode: "MEDIA_UPLOAD" internally.
Important: The field publish_type is NOT supported. Use draft: true for Creator Inbox flow.
Photo drafts use the /v2/post/publish/content/init/ endpoint with post_mode: "MEDIA_UPLOAD".
Video drafts use the dedicated /v2/post/publish/inbox/video/init/ endpoint.
When draft: true, the video.upload scope is required. When draft is false or omitted
(direct post), the video.publish scope is required. For Creator Inbox, TikTok app version
must be 31.8 or higher.
properties:
draft:
type: boolean
description: |
When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing
immediately. The creator receives an inbox notification to complete posting via TikTok's
editing flow. Maps to TikTok API post_mode: "MEDIA_UPLOAD" (photos) or the dedicated
inbox endpoint (videos). When false or omitted, publishes directly via post_mode: "DIRECT_POST".
Note: publish_type is not a supported field. Use this field instead.
privacyLevel:
type: string
description: One of the values returned by the TikTok creator info API for the account
allowComment:
type: boolean
description: Allow comments on the post
allowDuet:
type: boolean
description: Allow duets (required for video posts)
allowStitch:
type: boolean
description: Allow stitches (required for video posts)
commercialContentType:
type: string
enum: [none, brand_organic, brand_content]
description: |
Type of commercial content disclosure. Sufficient on its own: "brand_organic"
("Your Brand") implies isBrandOrganicPost and "brand_content" ("Branded Content",
paid partnership) implies brandPartnerPromote, so you don't need to send the
boolean flags separately. Branded content cannot be posted with privacyLevel
SELF_ONLY.
brandPartnerPromote:
type: boolean
description: |
Whether the post promotes a brand partner (branded content / paid partnership).
Only needed to disclose BOTH types at once (set it alongside
commercialContentType "brand_organic"), or to override the value implied by
commercialContentType.
isBrandOrganicPost:
type: boolean
description: |
Whether the post promotes the creator's own brand (brand organic). Only needed
to disclose BOTH types at once (set it alongside commercialContentType
"brand_content"), or to override the value implied by commercialContentType.
contentPreviewConfirmed:
type: boolean
description: User has confirmed they previewed the content
expressConsentGiven:
type: boolean
description: User has given express consent for posting
mediaType:
type: string
enum: [video, photo]
description: Optional override. Defaults based on provided media items.
videoCoverTimestampMs:
type: integer
description: Optional for video posts. Timestamp in milliseconds to select which frame to use as thumbnail (defaults to 1000ms/1 second). Ignored when videoCoverImageUrl is provided.
minimum: 0
videoCoverImageUrl:
type: string
format: uri
description: Optional for video posts. URL of a custom thumbnail image (JPG, PNG, or WebP, max 20MB). The image is stitched as a single frame at the start of the video and used as the cover. Overrides videoCoverTimestampMs when provided.
photoCoverIndex:
type: integer
description: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image).
minimum: 0
autoAddMusic:
type: boolean
description: When true, TikTok may add recommended music (photos only)
videoMadeWithAi:
type: boolean
description: Set true to disclose AI-generated content
description:
type: string
maxLength: 4000
description: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated.
TelegramPlatformData:
type: object
properties:
parseMode:
type: string
enum: [HTML, Markdown, MarkdownV2]
description: Text formatting mode for the message (default is HTML)
disableWebPagePreview:
type: boolean
description: Disable link preview generation for URLs in the message
disableNotification:
type: boolean
description: Send the message silently (users will receive notification without sound)
protectContent:
type: boolean
description: Protect message content from forwarding and saving
description: Text, images (up to 10), videos (up to 10), and mixed media albums. Captions up to 1024 chars for media, 4096 for text-only.
SnapchatPlatformData:
type: object
properties:
contentType:
type: string
enum: [story, saved_story, spotlight]
default: story
description: "Content type: story (ephemeral 24h, default), saved_story (permanent on Public Profile), spotlight (video feed)"
description: "Requires a Public Profile. Single media item only. Content types: story (ephemeral 24h), saved_story (permanent, title max 45 chars), spotlight (video, max 160 chars)."
RedditPlatformData:
type: object
properties:
subreddit:
type: string
description: Target subreddit name (without "r/" prefix). Overrides the default. Use GET /v1/accounts/{id}/reddit-subreddits to list options.
example: socialmedia
title:
type: string
maxLength: 300
description: Post title. Defaults to the first line of content, truncated to 300 characters.
url:
type: string
format: uri
description: URL for link posts. If provided (and forceSelf is not true), creates a link post instead of a text post.
forceSelf:
type: boolean
description: When true, creates a text/self post even when a URL or media is provided.
flairId:
type: string
description: Flair ID for the post. Required by some subreddits. Use GET /v1/accounts/{id}/reddit-flairs?subreddit=name to list flairs.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
flairText:
type: string
description: Custom flair text, for subreddits that allow free-text flair. Ignored when flairId is provided (flairId wins).
nsfw:
type: boolean
default: false
description: Mark the post as NSFW (Not Safe For Work / over 18).
spoiler:
type: boolean
default: false
description: Mark the post as a spoiler. The subreddit must have spoiler tagging enabled for this to take effect.
sendreplies:
type: boolean
default: true
description: Whether to receive inbox replies for comments on this post. Set to false to opt out.
nativeVideo:
type: boolean
description: >
Controls Reddit's native video upload flow. When true (default for video mediaItems),
the video is uploaded to Reddit's CDN and submitted with kind=video so it renders as
an embedded Reddit video player. Reddit transcodes server-side (1080p/30fps cap). Set
to false to fall back to a legacy link post. If the subreddit blocks video posts, the
upload falls back to a link post automatically.
default: true
videogif:
type: boolean
description: When true (and nativeVideo is active), submits the video as a silent videogif (kind=videogif). Use for short looping clips without audio.
videoPosterUrl:
type: string
format: uri
description: Optional poster/thumbnail image URL for native video posts. If omitted, the first frame of the video is extracted and used automatically.
description: Posts are either link (with URL/media), native video (via nativeVideo), or self (text-only). Use forceSelf to override. Subreddit defaults to the account's configured one. Some subreddits require a flair.
BlueskyPlatformData:
type: object
properties:
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.
enabled:
type: boolean
description: |
Whether the user explicitly activated this account. false means the account was
created as a side effect (e.g., posting account auto-created when user connected
ads first). Posting UI and scheduler ignore accounts with enabled: false.
metadata:
type: object
description: |
Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes:
- qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN)
- nameStatus: Display name review status (APPROVED, PENDING_REVIEW, DECLINED, or NONE). A declined or pending display name does not by itself block sending; sendability is reported separately via health_status (can_send_message).
- messagingLimitTier: Maximum unique business-initiated conversations per 24h rolling window (TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED). Scales automatically as quality rating improves.
- verifiedName: Meta-verified business display name
- displayPhoneNumber: Formatted phone number (e.g., "+1 555-123-4567")
- wabaId: WhatsApp Business Account ID
- phoneNumberId: Meta phone number ID
For LinkedIn accounts, profileData carries the profile details refreshed on each daily snapshot:
- profileData.bio: The member's headline for personal accounts, or the organization description for organization accounts. null when the member has not set one.
- profileData.extraData.vanityName: The member's profile slug, i.e. the /in/{vanityName} segment of profileUrl. Personal accounts only; an organization's own slug is in metadata.organizationInfo.vanityName.
AccountWithFollowerStats:
allOf:
- $ref: '#/components/schemas/SocialAccount'
- type: object
properties:
currentFollowers: { type: number, description: Current follower count }
lastUpdated: { type: string, format: date-time }
growth: { type: number, description: Follower change over period }
growthPercentage: { type: number, description: Percentage growth }
dataPoints: { type: number, description: Number of historical snapshots }
accountStats:
type: object
description: |
Platform-specific account stats from the latest daily snapshot.
Fields vary by platform. Only present if metadata has been captured.
properties:
followingCount: { type: number, description: Number of accounts being followed }
mediaCount: { type: number, description: Total media posts (Instagram) }
videoCount: { type: number, description: "Total videos (YouTube, TikTok)" }
tweetCount: { type: number, description: Total tweets (X/Twitter) }
postsCount: { type: number, description: Total posts (Bluesky) }
pinCount: { type: number, description: Total pins (Pinterest) }
totalViews: { type: number, description: Total channel views (YouTube) }
likesCount: { type: number, description: Total likes received (TikTok) }
monthlyViews: { type: number, description: Monthly profile views (Pinterest) }
listedCount: { type: number, description: Lists the user appears on (X/Twitter) }
boardCount: { type: number, description: Total boards (Pinterest) }
ApiKey:
type: object
properties:
id: { type: string }
name: { type: string }
keyPreview: { type: string }
expiresAt: { type: string, format: date-time }
createdAt: { type: string, format: date-time }
key:
type: string
description: Returned only once, on creation
scope:
type: string
enum: [full, profiles]
description: "'full' grants access to all profiles, 'profiles' restricts to specific profiles"
default: full
profileIds:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
color: { type: string }
description: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'.
permission:
type: string
enum: [read-write, read]
description: "'read-write' allows all operations, 'read' restricts to GET requests only"
default: read-write
disabledResourceGroups:
type: array
items:
type: string
enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
description: >-
Resource groups this key can NOT access (opt-out denylist). Absent
or empty means legacy full access. A key with any group disabled is
a restricted key (zrk_ prefix) and can never manage API keys,
invites, or member identity. Each operation's group is published as
x-resource-group. With 'messages' disabled, the key cannot read or
send private messages through any API surface, and it cannot create
or edit a webhook subscription broader than itself: it cannot
subscribe to, test-fire, redeliver, or read delivery logs for
message events. Subscriptions created earlier, from the dashboard,
or with a full-access key keep delivering whatever their own
`disabledResourceGroups` allows, so restricting an existing
integration end to end means restricting the subscription too.
OAuth connector tokens (AI assistants and MCP clients) resolve
against the same registry, but their groups are not settable yet:
treat an authorized connector as full access.
ConnectedApp:
type: object
description: |
An OAuth client (AI assistant / MCP connector) authorized by the user and still
holding at least one live token.
properties:
clientId: { type: string }
clientName:
type: string
description: 'Name the client declared at registration. Registration is open, so this is self-declared and not verified.'
redirectHost:
type: [string, "null"]
description: 'Host of the client''s registered redirect URI (non-http schemes are shown as scheme//host). The destination an impostor cannot fake.'
scopes:
type: array
items: { type: string }
description: Scopes granted on the most recent token.
authorizedAt: { type: [string, "null"], format: date-time }
lastUsedAt:
type: [string, "null"]
format: date-time
description: Last time any of the client's live tokens authenticated a request.
tokenCount:
type: integer
description: 'Live tokens held by the client (an active session is typically one access plus one refresh token).'
BillingSnapshot:
type: object
description: |
Account billing state — plan, cycle, balance, spend caps, and payment /
access status. Returned by `GET /v1/billing`.
properties:
billingSystem:
type: string
enum: [metronome, stripe, 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, Metronome enrollment, 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 Metronome's invoice
breakdown (the CHARGE view). Returned by `GET /v1/usage`.
properties:
supported:
type: boolean
description: False for legacy Stripe accounts (no Metronome invoice to split); `days` and `totals` are then empty/zero.
granularity:
type: string
enum: [day, month, total]
days:
type: array
description: One row per bucket. Empty when `granularity=total`. `date` is a UTC date (month buckets use the 1st).
items:
type: object
properties:
date: { type: string, format: date }
accounts: { type: number }
numbers: { type: number }
calls: { type: number }
sms: { type: number }
dlc: { type: number, description: 10DLC registration (brand + campaign) fees. }
xApi: { type: number }
credits: { type: number, description: Applied credits/discounts (negative). }
other: { type: number }
totals:
type: object
description: Sum of each product over the whole window (USD), plus `total`. Unaffected by `granularity`.
properties:
accounts: { type: number }
numbers: { type: number }
calls: { type: number }
sms: { type: number }
dlc: { type: number }
xApi: { type: number }
credits: { type: number }
other: { type: number }
total: { type: number }
lineItems:
type: array
description: Per-invoice-line-item rows (largest spend first) for a detailed breakdown.
items:
type: object
properties:
name: { type: string }
product:
type: string
enum: [accounts, numbers, calls, sms, dlc, xApi, credits, other]
totalUsd: { type: number }
quantity: { type: number }
peaks:
type: object
description: Peak counts over the window (Metronome COUNT metrics + live active-number count).
properties:
accounts: { type: integer }
numbers: { type: integer }
callUsage:
type: object
description: Billable call volumes over the window.
properties:
whatsapp:
type: object
properties:
count: { type: integer }
minutes: { type: integer }
pstn:
type: object
properties:
count: { type: integer }
minutes: { type: integer }
period:
type: object
properties:
start: { type: string, format: date-time }
end: { type: string, format: date-time }
source:
type: string
enum: [cycle, window]
description: '`cycle` = a real billing period resolved; `window` = trailing/custom window (or cycle fallback).'
tax:
type: [object, "null"]
description: |
Estimated tax on the window's net `totals.total`, computed with
Stripe Tax against the billing address (the same engine the real
invoice uses; invoices apply exclusive tax, so the card is charged
total + tax). Null when the account has no billing address on
file, the total is zero or negative, or the estimate failed.
properties:
taxUsd: { type: number, description: "Estimated tax in USD, added on top of `totals.total`." }
ratePercent: { type: [number, "null"], description: "Combined rate percentage, e.g. 21." }
jurisdictionLabel: { type: [string, "null"], description: 'Human jurisdiction label, e.g. "ES VAT" or "WA sales tax".' }
reverseCharge: { type: boolean, description: True for EU/UK B2B reverse charge (0 tax added by design). }
UsageStats:
type: object
description: |
Plan and usage stats. The response shape depends on `billingSystem`:
* Stripe users (default): per-period counters like `usage.uploads` and
`usage.profiles` are returned, scoped by the plan's `limits`.
* Metronome users (usage-based): `limits` are unlimited (-1). The
`usage` block carries connected-account and per-X-operation counts,
and the `spend` block carries current-period costs plus the X cap.
properties:
billingSystem:
type: string
enum: [stripe, metronome]
description: Which billing system the account is on. Shape of `usage`/`spend` differs.
planName: { type: string }
billingPeriod: { type: string, enum: [monthly, yearly] }
signupDate: { type: string, format: date-time }
billingAnchorDay: { type: integer, description: "Day of month (1-31) when the billing cycle resets" }
hasAccess:
type: boolean
description: True if the account is in good standing. False for past-due/unpaid/paused subscriptions.
customerId:
type: [string, "null"]
description: Stripe customer ID, when present.
isInvitedUser:
type: boolean
description: True if this is a team member; limits/usage reflect the account owner.
autoUpgradeEnabled:
type: boolean
description: Stripe-only. Always false for Metronome users.
limits:
type: object
description: Plan limits. For Metronome users both fields are `-1` (unlimited).
properties:
uploads: { type: integer }
profiles: { type: integer }
usage:
type: object
description: |
Per-period usage counts. Fields present depend on `billingSystem`:
Stripe returns `uploads` / `profiles` / `lastReset`;
Metronome returns `connectedAccounts` / `xApiCalls` / `xApiCallsByOperation`.
properties:
# Stripe fields
uploads: { type: integer, description: "Stripe users only. Uploads consumed in the current period." }
profiles: { type: integer, description: "Stripe users only. Profiles currently owned." }
lastReset: { type: string, format: date-time, description: "Stripe users only." }
# Metronome fields
connectedAccounts:
type: integer
description: "Metronome users only. Accounts currently connected across the team."
xApiCalls:
type: object
deprecated: true
description: |
**Deprecated.** Legacy 3-tier aggregate. Operations outside the
three historical prices ($0.005/$0.010/$0.015) — notably the
$0.200 "Posts with URL" tier added April 2026 — are silently
excluded from this shape. Use `xApiCallsByOperation` instead;
it captures every tier and is the source of truth for
per-operation call counts.
properties:
x_api_005: { type: integer, description: "Calls at $0.005 per call (reads, lists, bookmarks, content manage, etc.)" }
x_api_010: { type: integer, description: "Calls at $0.010 per call (user reads, DM reads, follow reads, trends, list create, privacy update)" }
x_api_015: { type: integer, description: "Calls at $0.015 per call (posts/replies, DM sends, user interactions)" }
xApiCallsByOperation:
type: object
additionalProperties:
type: integer
description: |
Metronome users only. Per-operation X API call counts keyed by
operation (e.g. `posts_read`, `content_create`,
`content_create_with_url`). Resolve each key to price and metadata
via `GET /v1/billing/x-pricing`. This is the canonical source —
covers every price tier including the $0.200 URL tier that
`xApiCalls` excludes.
example:
posts_read: 42
content_create: 7
content_create_with_url: 3
dm_interaction_create: 1
spend:
type: object
description: "Metronome users only. Current-period spend summary."
properties:
currentPeriodCents:
type: integer
description: Total current-period spend in cents (all products combined).
creditsRemainingCents:
type: integer
description: Free-tier credit remaining in cents. Applied before any charge.
xSpendCents:
type: integer
description: |
Current-period X/Twitter API spend in cents, summed from
`xApiCallsByOperation` × per-operation prices. Tier-agnostic
(covers every price including the $0.200 URL tier). Rounded
up for conservative enforcement against `xSpendLimitCents`.
xSpendLimitCents:
type: [integer, "null"]
description: |
Monthly X spend cap set by the account owner, or null if no cap.
When current X spend hits this cap, analytics and inbox sync are
auto-paused for X accounts. Publishing is never blocked by this cap.
XApiPricing:
type: object
description: |
Canonical X/Twitter API pricing table. Zernio passes X API costs through
at exact rates with zero markup, so every call you make has a known per-unit
price. Use this payload alongside `/v1/usage-stats` (which returns
per-operation call counts via `xApiCallsByOperation`) to compute exact
cost attribution by X action.
properties:
currency: { type: string, example: USD }
markup: { type: string, example: "0%", description: "Always 0% — Zernio does not mark up X API rates." }
source: { type: string, format: uri, example: "https://developer.x.com/#pricing" }
lastVerified:
type: string
format: date
description: Date the prices were last verified against X's published rates.
tiers:
type: array
description: Rollup of operations grouped by their per-call price.
items:
type: object
properties:
tier:
type: string
description: |
Tier key derived from price (e.g. `x_api_005` for $0.005,
`x_api_200` for $0.200). The first three keys map to the
legacy `xApiCalls` aggregate; new tiers (e.g. `x_api_200`
for the URL tier added April 2026) are surfaced here but
not in the legacy shape.
example: x_api_005
pricePerCallUsd: { type: number, example: 0.005 }
operationCount: { type: integer, example: 13 }
operations:
type: array
description: Flat list of every X operation Zernio can perform, with its rate.
items: { $ref: '#/components/schemas/XApiOperation' }
XApiOperation:
type: object
description: A single X API operation with its per-call price and the Zernio platform methods that trigger it.
properties:
operation:
type: string
example: posts_read
description: Internal operation key. Matches keys in `xApiCallsByOperation`.
eventType:
type: string
example: x_posts_read
description: Metronome `event_type` emitted when this operation runs.
displayName:
type: string
example: "X API: Posts Read"
description: Human-readable label shown on Metronome invoices.
pricePerCallUsd:
type: number
example: 0.005
pricePerCallCents:
type: number
example: 0.5
description: Per-call price in cents. Fractional values are intentional.
tier:
type: string
description: |
Tier key derived from `pricePerCallUsd` (e.g. `x_api_005` for
$0.005, `x_api_200` for $0.200). Useful for grouping operations
by price in dashboards.
example: x_api_005
triggeredBy:
type: array
description: Zernio platform methods that emit this operation, with their metering rule.
items:
type: object
properties:
method:
type: string
example: getPostAnalytics
description: Zernio platform method name.
metering:
type: string
enum: [always, analytics_optin, inbox_optin, absorbed]
description: |
When the method actually bills the user:
* `always` — every call is metered
* `analytics_optin` — only when the X account has analytics enabled
* `inbox_optin` — only when the X account has inbox sync enabled
* `absorbed` — Zernio eats the cost, never billed
PostAnalytics:
type: object
properties:
impressions: { type: integer, example: 0 }
reach: { type: integer, example: 0 }
likes: { type: integer, example: 0 }
comments: { type: integer, example: 0 }
shares: { type: integer, example: 0 }
saves: { type: integer, example: 0, description: 'Number of saves/bookmarks (Instagram, Pinterest, X/Twitter)' }
clicks: { type: integer, example: 0 }
views: { type: integer, example: 0 }
follows: { type: integer, example: 0, description: 'Instagram feed posts and stories only: organic accounts that started following from this post. 0 for reels and other platforms.' }
igReelsAvgWatchTime: { type: integer, example: 0, description: 'Instagram Reels only: average watch time per play, in milliseconds. 0 for non-Reels media and other platforms.' }
igReelsVideoViewTotalTime: { type: integer, example: 0, description: 'Instagram Reels only: total watch time including replays, in milliseconds. 0 for non-Reels media and other platforms.' }
reelsSkipRate: { type: number, example: 0, description: 'Instagram Reels only: the rate of initial views that skipped the reel within its first 3 seconds, as reported by Meta. Passed through exactly as Meta reports it, with no rescaling, so do not assume a 0-1 share. 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.' }
reposts: { type: integer, example: 0, description: 'Instagram only: reposts of the media by other users, minus deleted reposts. Available on feed posts, reels and stories. 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 }
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 social accounts (followerCount and followersLastUpdated only included if user has analytics add-on)
items:
$ref: '#/components/schemas/SocialAccount'
hasAnalyticsAccess:
type: boolean
description: Whether user has analytics add-on access
# LinkedIn Aggregate Analytics Responses
LinkedInAggregateAnalyticsTotalResponse:
type: object
description: Response for TOTAL aggregation (lifetime totals)
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: personal }
username: { type: string }
aggregation: { type: string, enum: [TOTAL] }
dateRange:
type: [object, "null"]
properties:
startDate: { type: string, format: date }
endDate: { type: string, format: date }
analytics:
type: object
properties:
impressions: { type: integer, description: Total impressions across all posts }
reach: { type: integer, description: Unique members reached across all posts }
reactions: { type: integer, description: Total reactions across all posts }
comments: { type: integer, description: Total comments across all posts }
shares: { type: integer, description: Total reshares across all posts }
saves: { type: integer, description: Total times posts were saved (personal accounts only) }
sends: { type: integer, description: Total times posts were sent via LinkedIn messaging (personal accounts only) }
engagementRate: { type: number, description: 'Overall engagement rate, as 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'
PostCreateResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
PostUpdateResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
warnings:
type: array
items:
type: string
PostDeleteResponse:
type: object
properties:
message:
type: string
PostRetryResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
# Profiles Responses
ProfilesListResponse:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/Profile'
total:
type: integer
description: 'Total matching profiles across all pages. Present only when limit or skip was passed.'
skip:
type: integer
description: 'Offset applied. Present only when limit or skip was passed.'
limit:
type: integer
description: Echo of the limit query param. Present only when it was passed.
ProfileCreateResponse:
type: object
properties:
message:
type: string
profile:
$ref: '#/components/schemas/Profile'
ProfileGetResponse:
type: object
properties:
profile:
$ref: '#/components/schemas/Profile'
ProfileUpdateResponse:
type: object
properties:
message: { type: string }
profile:
$ref: '#/components/schemas/Profile'
ProfileDeleteResponse:
type: object
properties:
message: { type: string }
AccountsListResponse:
type: object
required: [accounts, hasAnalyticsAccess]
properties:
accounts:
type: array
items: { $ref: '#/components/schemas/SocialAccount' }
hasAnalyticsAccess:
type: boolean
description: Whether user has analytics add-on access
pagination:
description: Only present when page/limit params are provided
$ref: '#/components/schemas/Pagination'
FollowerStatsResponse:
type: object
properties:
accounts:
type: array
items:
$ref: '#/components/schemas/AccountWithFollowerStats'
stats:
type: object
additionalProperties:
type: array
items:
type: object
properties:
date: { type: string, format: date }
followers: { type: number }
dateRange:
type: object
properties:
from: { type: string, format: date-time }
to: { type: string, format: date-time }
granularity: { type: string }
UploadedFile:
type: object
properties:
type:
type: string
enum: [image, video, document]
url: { type: string, format: uri }
filename: { type: string }
size: { type: integer }
mimeType: { type: string }
MediaUploadResponse:
type: object
properties:
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
UploadTokenResponse:
type: object
properties:
token: { type: string }
uploadUrl: { type: string, format: uri }
expiresAt: { type: string, format: date-time }
status:
type: string
enum: [pending, completed, expired]
UploadTokenStatusResponse:
type: object
properties:
token: { type: string }
status:
type: string
enum: [pending, completed, expired]
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
createdAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
completedAt: { type: [string, "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–100)." }
cpc: { type: number, description: "Cost per click in native currency." }
cpm: { type: number, description: "Cost per 1000 impressions in native currency." }
conversions: { type: number, description: "Sum of conversion events over the range. Fractional values are normal (attribution splitting + Google modeled conversions). Meta: events matching the campaign optimization goal. Google: tracked conversions. X / LinkedIn: reported website/lead conversions (added 2026-07)." }
costPerConversion: { type: number }
actions:
type: object
additionalProperties: { type: number }
description: "Per-action-type counts merged across all ads on this day. Keys are platform-native action types."
actionValues:
type: object
additionalProperties: { type: number }
description: "Monetary mirror of `actions` in native currency."
purchaseValue: { type: number, description: "Sum of purchase-type action values on this day, native currency." }
roas: { type: number, description: "Derived purchaseValue / spend." }
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)." }
currency:
type: [string, "null"]
description: "ISO 4217 code of the ad account (e.g. USD, THB). All money values in `summary` and `daily` are in this currency."
analytics:
type: object
properties:
summary: { $ref: '#/components/schemas/AdMetrics' }
daily:
type: array
items:
allOf:
- { $ref: '#/components/schemas/AdMetrics' }
- type: object
properties:
date: { type: string, format: date }
breakdowns:
type: object
additionalProperties:
type: array
items: { type: object }
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."
costPerConversion:
type: number
description: "Derived spend / conversions in the same currency as spend. 0 when conversions is 0."
actions:
type: object
additionalProperties:
type: integer
description: "Per-action-type counts summed over the date range, keyed by the platform's action-type names. Meta: raw Insights action_type keys (link_click, offsite_conversion.fb_pixel_purchase, onsite_conversion.lead_grouped, ...) — both engagement and conversion events. 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.
Note that `engagement` is not the sum of these: Meta's own
`post_engagement` and `page_engagement` totals already contain the
individual interactions, and all of them are counted into `engagement`.
Use these fields when you need a specific interaction, and `engagement`
only as the coarse total it has always been.
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`).
AdBudget:
type: object
description: Budget amount in the ad account's native currency (see the campaign's `currency` field for the code).
required: [amount, type]
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
TargetingSpec:
type: object
description: |
Normalized, platform-agnostic ad-targeting spec. Every field is optional, an
empty object targets the platform's default broadest audience. Field names are
camelCase and identical across `POST /v1/ads/create` (the `targeting` object),
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences, so a
spec resolved once can be reused verbatim.
Entity ids (`regions[].key`, `cities[].key`, `zips[].key`, `metros[].key`,
`interests[].id`, `behaviors[].id`) are the platform's opaque identifiers
resolved via `GET /v1/ads/targeting/search`. A spec is therefore meaningful only
for the platform it was built against, except the portable fields (`countries`,
`ageMin`/`ageMax`, `gender`, `incomeTier`, `languages`) which carry across
platforms. Fields a platform cannot honour are rejected at create time with
`INVALID_FIELD_VALUE` naming the offending field (not silently dropped).
properties:
countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['US'])." }
regions:
type: array
description: "Region/state targeting. `key` is the platform location ID from /v1/ads/targeting/search?dimension=geo&geoType=region."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
cities:
type: array
description: "City targeting. Optional `radius` + `distanceUnit` extend beyond the city limits; both must be set together or both omitted. `radius` is only honoured on platforms whose capability map allows city radius (Meta)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
radius: { type: number, description: "Radius around the city. Requires distanceUnit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng), which allows a smaller radius." }
distanceUnit: { type: string, enum: [mile, kilometer], description: "Required if radius is set." }
zips:
type: array
description: "Postal/ZIP targeting. `key` is the platform's postal location ID (e.g. Meta `US:94304`). Supported on Meta, Google, TikTok, Pinterest, X."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
metros:
type: array
description: "DMA / metro-area targeting. `key` is the platform's metro ID (e.g. Meta `DMA:807`)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
customLocations:
type: array
description: "Point-radius (lat/lng) targeting (Meta custom_locations / Google proximity). Honoured only where the capability map allows radius (Meta)."
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number, description: "Positive radius around the point." }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string, description: "Optional label, sent to Meta as `address_string`. latitude/longitude take precedence for the pin location." }
excludedLocations:
type: object
description: "Geo to exclude from the audience. Mirrors the inclusion geo shape: excluded cities can carry a radius catchment and excluded custom (lat/lng) pins are supported, both on Meta (excluded_geo_locations)."
properties:
countries: { type: array, items: { type: string } }
regions:
type: array
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
cities:
type: array
description: "Cities to exclude. Optional `radius` + `distanceUnit` exclude a catchment around the city (both must be set together or both omitted); Meta honours the radius on excluded cities."
items:
type: object
required: [key]
properties:
key: { type: string }
radius: { type: number, description: "Radius around the excluded city. Requires distanceUnit." }
distanceUnit: { type: string, enum: [mile, kilometer], description: "Required if radius is set." }
zips:
type: array
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
places:
type: array
description: "Named points of interest to exclude. `key` from /v1/ads/targeting/search."
items:
type: object
required: [key]
properties:
key: { type: string }
neighborhoods:
type: array
description: "Named neighbourhood areas to exclude. `key` from /v1/ads/targeting/search."
items:
type: object
required: [key]
properties:
key: { type: string }
customLocations:
type: array
description: "Point-radius (lat/lng) pins to exclude (Meta excluded_geo_locations.custom_locations). Mirrors the inclusion customLocations shape."
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number, description: "Positive radius around the point." }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string, description: "Optional label, sent to Meta as `address_string`. latitude/longitude take precedence for the pin location." }
ageMin: { type: integer, minimum: 13, maximum: 100, 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 }
industries: { type: array, items: { type: string }, description: "LinkedIn B2B only. Industry URN id fragments." }
companySizes: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
seniorities: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
jobFunctions: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
audienceInclude: { type: array, items: { type: string }, description: "Platform audience IDs to include." }
audienceExclude: { type: array, items: { type: string }, description: "Platform audience IDs to exclude." }
Ad:
type: object
properties:
_id: { type: string }
name: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status. Derived from the platform `effective_status`, so it inherits ancestor pauses (an ACTIVE ad under a PAUSED campaign reads `paused`). For the ad's own on/off toggle use `configuredStatus`; for the review state use `reviewStatus`." }
configuredStatus:
type: [string, "null"]
description: "The ad's own on/off toggle as configured on the platform (Meta `configured_status`: ACTIVE / PAUSED), unaffected by ancestor (ad set / campaign) pauses. Distinct from `status`, which is the ancestor-cascaded delivery status. Only present for Meta ads synced after this field was added."
example: ACTIVE
reviewStatus:
allOf: [{ $ref: '#/components/schemas/AdReviewStatus' }]
description: "Platform review state of this ad, independent of delivery `status` / `configuredStatus`. Absent when the platform reports no review signal."
adType: { type: string, enum: [boost, standalone] }
creativeType:
type: [string, "null"]
enum: [carousel, video, document, image, null]
description: "Creative format, classified from the media the creative carries. `null` when the creative carries no media to classify — an unsynced creative and a genuine text-only ad are indistinguishable, so neither is guessed at. Returned by `GET /v1/ads`, `GET /v1/ads/{adId}` and the ad nodes of `GET /v1/ads/tree`."
example: video
goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales, 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. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views." }
isExternal: { type: boolean, description: True for ads synced from platform ad managers }
budget:
type: [object, "null"]
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
metrics:
anyOf:
- $ref: '#/components/schemas/AdMetrics'
- type: "null"
platformAdId: { type: string }
platformAdAccountId: { type: string }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
campaignName: { type: string }
adSetName: { type: string }
platformObjective:
type: [string, "null"]
description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads."
example: OUTCOME_SALES
optimizationGoal:
type: [string, "null"]
description: |
What the delivery system optimizes for, at ad-set level. The value space depends on `platform`:
- Meta: ad set `optimization_goal` (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS).
- LinkedIn: the campaign's EFFECTIVE `optimizationTargetType`, refreshed from LinkedIn on every
sync rather than echoing what was passed on create. `NONE` means manual bidding, and it is a
real value, not missing data. Auto-bid values are MAX_IMPRESSION / MAX_CLICK / MAX_CONVERSION /
MAX_VIDEO_VIEW / MAX_LEAD / MAX_REACH; target-cost values are TARGET_COST_PER_CLICK /
TARGET_COST_PER_IMPRESSION / TARGET_COST_PER_VIDEO_VIEW; cost-cap values are the
CAP_COST_AND_MAXIMIZE_* family.
example: OFFSITE_CONVERSIONS
costType:
type: [string, "null"]
description: |
LinkedIn only. The campaign's EFFECTIVE cost model (billing event) as applied by LinkedIn,
refreshed on every sync rather than echoing what was passed on create. One of `CPM` (cost per
thousand impressions), `CPC` (cost per click) or `CPV` (cost per video view). On LinkedIn this is
the axis that pairs with `bidAmount`; there is no `bidStrategy`. For campaign type
SPONSORED_INMAILS, `CPM` bills as cost-per-send x 1000. `null` for non-LinkedIn ads.
example: CPC
servingStatuses:
type: array
items: { type: string }
description: |
LinkedIn only. Why the parent campaign is (or is not) delivering, verbatim from LinkedIn.
A campaign can report `status: ACTIVE` and still serve nothing; this array is what says so.
- `[]` means no serving data: a non-LinkedIn ad, or a LinkedIn ad not yet re-synced.
- `["RUNNABLE"]` means the campaign is eligible to serve.
- Anything else is a hold. Known values include ACCOUNT_SERVING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD,
ACCOUNT_END_DATE_HOLD, CAMPAIGN_START_DATE_HOLD, CAMPAIGN_END_DATE_HOLD,
CAMPAIGN_TOTAL_BUDGET_HOLD, CAMPAIGN_AUDIENCE_COUNT_HOLD, CAMPAIGN_GROUP_START_DATE_HOLD,
CAMPAIGN_GROUP_END_DATE_HOLD, CAMPAIGN_GROUP_TOTAL_BUDGET_HOLD, CAMPAIGN_GROUP_STATUS_HOLD and
STOPPED. The list is open on purpose, so treat unrecognized values as holds rather than errors.
The end-date and total-budget holds are terminal and surface as `status: completed`; the rest
surface as `status: paused`. Note that a hold is not the only cause of zero delivery: with
manual, target-cost or cost-cap bidding, a `bidAmount` of 0 stops delivery while
`servingStatuses` still reads `["RUNNABLE"]`. Check `costType` / `bidAmount` /
`optimizationGoal` as well.
example: ["ACCOUNT_TOTAL_BUDGET_HOLD"]
platformAdAccountName:
type: [string, "null"]
description: |
Human-readable advertiser/account name (Meta `AdAccount.name`, TikTok
`advertiser_name`, LinkedIn / X / Pinterest equivalents). Refreshed every
sync so platform-side renames propagate within one cycle. `null` when the
platform doesn't return a name or the sync hasn't run yet.
example: "Zernio - previously Late"
platformCreatedAt:
type: [string, "null"]
format: date-time
description: |
Platform-reported creation timestamp (Meta `created_time`, TikTok `create_time`).
Distinct from `createdAt` which reflects when Zernio first synced the doc — for
sort/filter by "when the ad was actually created on the platform", read this field.
`null` for legacy ads synced before this field was added; aggregations fall back
to `createdAt` in that case.
bidStrategy:
anyOf:
- $ref: '#/components/schemas/BidStrategy'
- type: "null"
description: |
Ad-set bid strategy (overrides campaign level on Meta). Populated for Meta and
TikTok. TikTok's native `bid_type` is normalized to the cross-platform Meta enum:
`BID_TYPE_NO_BID` -> `LOWEST_COST_WITHOUT_CAP`, `BID_TYPE_CUSTOM` ->
`LOWEST_COST_WITH_BID_CAP`, deep_bid_type=MIN_ROAS or roas_bid>0 ->
`LOWEST_COST_WITH_MIN_ROAS`, `BID_TYPE_MAX_CONVERSION` -> `LOWEST_COST_WITHOUT_CAP`.
example: LOWEST_COST_WITHOUT_CAP
bidAmount:
type: [number, "null"]
description: |
Bid amount in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).
- Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here). Populated
when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; `null` for auto-bid
(`LOWEST_COST_WITHOUT_CAP`).
- TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`
(whichever is set on the ad group). TikTok stores all three in whole currency units.
- LinkedIn source: the campaign's EFFECTIVE `unitCost`, refreshed on every sync rather than
echoing what was passed on create. Its meaning depends on the bidding mode implied by
`optimizationGoal`: bid amount (manual), target cost, or cost cap. It pairs with `costType`,
NOT with `bidStrategy`, which LinkedIn does not have. A value of `0` is a real, delivery-
stopping configuration and not "unset", so do not gate this field on `bidStrategy` for
LinkedIn ads.
Source: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).
example: 5
roasAverageFloor:
type: [number, "null"]
description: |
Minimum ROAS as a decimal multiplier (2.0 = 2.0x ROAS). Populated when bidStrategy
is `LOWEST_COST_WITH_MIN_ROAS`.
- Meta source: decoded from `bid_constraints.roas_average_floor` (Meta stores as
fixed-point int × 10000; we return the decimal).
- TikTok source: `roas_bid` on the ad group (already a decimal).
Source: facebook-business-sdk-codegen api_specs/specs/AdCampaignBidConstraint.json.
example: 2.0
promotedObject:
type: [object, "null"]
description: "Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads."
properties:
custom_event_type: { type: string, description: "Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART)", example: PURCHASE }
pixel_id: { type: string, description: Meta pixel ID }
page_id: { type: string, description: Facebook page ID }
application_id: { type: string, description: Facebook app ID }
product_set_id: { type: string, description: Product catalog set ID }
creative:
type: [object, "null"]
description: Platform-specific creative data. Fields vary by platform.
properties:
thumbnailUrl: { type: [string, "null"], description: Primary thumbnail/image URL }
imageUrl: { type: string, description: Alternative image URL }
videoId: { type: [string, "null"], description: "Meta video ID for VIDEO-type ads. Null for non-video ads. Callers that need an embeddable MP4 can call GET /{videoId}?fields=source with the page access token." }
videoUrl: { type: [string, "null"], description: "Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads." }
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 — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead." }
effectiveObjectStoryId: { type: [string, "null"], description: "Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it." }
pageId: { type: [string, "null"], description: "Facebook Page backing the creative (Meta only). What the `pageId` filter on /v1/ads, /v1/ads/campaigns and /v1/ads/tree matches against. Absent for non-Meta ads and rare Meta creatives with no page signal." }
effectiveInstagramMediaId: { type: [string, "null"], description: "Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad." }
instagramUserId: { type: [string, "null"], description: "Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media." }
instagramPermalinkUrl: { type: [string, "null"], description: "Meta `instagram_permalink_url` — public Instagram post URL of the boosted media." }
mediaUrls:
type: array
items: { type: string }
description: All media URLs for this ad (carousel images, multiple assets). Populated for Meta (carousel child_attachments), Google Ads (responsive display marketing_images), and LinkedIn (multi-image posts).
isServing: { type: [boolean, "null"], description: "LinkedIn only. Whether LinkedIn is currently serving this specific creative. Complements the ad-level `servingStatuses`, which describes the parent campaign." }
servingHoldReasons:
type: array
items: { type: string }
description: |
LinkedIn only. Why this specific creative is not being served. Empty when it is serving.
A superset of the ad-level `servingStatuses`: it repeats the inherited campaign, campaign
group and account holds AND adds creative-only causes such as UNDER_REVIEW, REJECTED,
PROCESSING, PROCESSING_FAILED, FORM_HOLD (lead-gen-form creatives),
REFERRED_CONTENT_QUALITY_HOLD, JOB_POSTING_ON_HOLD and JOB_POSTING_INVALID (job ads).
Some values are format-specific and will never appear on other ad formats. The list is
open, so treat unrecognized values as holds rather than errors.
example: ["UNDER_REVIEW"]
body: { type: string, description: Ad copy/text }
googleHeadline: { type: string, description: Google Ads headline }
googleDescription: { type: string, description: Google Ads description }
linkUrl: { type: string, description: Destination URL }
pinterestImageUrl: { type: string }
pinterestTitle: { type: string }
pinterestDescription: { type: string }
targeting:
type: object
description: |
The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions).
For ads created through Zernio this is the spec you supplied. For external ads (synced from
Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest,
so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad;
the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`),
the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails.
schedule:
type: [object, "null"]
properties:
startDate: { type: string, format: date-time }
endDate: { type: string, format: date-time }
rejectionReason: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AdTreeAdSet:
type: object
description: Ad set (or ad group/line item depending on platform) with rolled-up metrics and child ads
properties:
platformAdSetId: { type: string }
adSetName: { type: string }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: Derived from child ad statuses }
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 — 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). 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:
type: [object, "null"]
description: Effective budget (back-compat). Use `budgetLevel` to disambiguate CBO vs ABO.
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
campaignBudget:
type: [object, "null"]
description: Campaign-level budget (CBO). Null for ABO campaigns.
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
budgetLevel:
type: [string, "null"]
enum: [campaign, adset]
description: "Canonical CBO/ABO indicator. See AdTreeCampaign.budgetLevel."
isBudgetScheduleEnabled:
type: boolean
default: false
description: "Meta-only. Mirrors Campaign.is_budget_schedule_enabled."
currency:
type: [string, "null"]
description: "ISO 4217 currency code for all budget amounts. Budgets are NOT normalized to USD."
metrics: { $ref: '#/components/schemas/AdMetrics' }
platformAdAccountId: { type: string }
platformAdAccountName: { type: [string, "null"], description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
accountId: { type: string }
profileId: { type: string }
advertisingChannelType:
type: [string, "null"]
description: "Google-only. Raw campaign.advertising_channel_type. See AdTreeCampaign.advertisingChannelType."
platformObjective: { type: [string, "null"], description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
optimizationGoal:
# 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 }
ConversionEvent:
type: object
description: |
A single conversion event to relay to the ad platform. All PII fields
(email, phone, names) are hashed with SHA-256 server-side using each
platform's normalization rules before they leave Zernio. Callers send
plaintext.
required: [eventName, eventTime, eventId, user]
properties:
eventName:
type: string
description: |
Standard event name (Purchase, Lead, CompleteRegistration, AddToCart,
InitiateCheckout, AddPaymentInfo, Subscribe, StartTrial, ViewContent,
Search, Contact, SubmitApplication, Schedule) or a custom string
(only supported on platforms that accept custom events — Meta and
OpenAI Ads).
Per-platform behavior:
- Meta: free-form; standard names match Meta's built-ins.
- Google: ignored — the conversion action's category determines the type.
- LinkedIn: ignored — the conversion rule's `type` is locked to the destination.
- OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; anything else is sent as a custom event with the name preserved.
example: Purchase
eventTime:
type: integer
description: When the conversion happened, in unix seconds.
example: 1744732800
eventId:
type: string
description: |
Unique dedup key. The same eventId must be used on pixel + CAPI
to prevent double-counting. Mapped to event_id on Meta,
transactionId on Google, eventId on LinkedIn (LinkedIn deduplicates
against Insight Tag events with the same eventId; the Insight Tag
event wins when both arrive).
example: order_abc_123
value:
type: number
description: Conversion value in the specified currency.
example: 99.5
currency:
type: string
description: ISO 4217 currency code.
example: USD
user:
type: object
description: User identity fields. More signals mean higher match rates.
properties:
email: { type: string, description: Plaintext email. Hashed server-side. }
phone: { type: string, description: "Phone number, ideally E.164. Hashed server-side." }
firstName: { type: string, description: Plaintext first name. Hashed server-side. }
lastName: { type: string, description: Plaintext last name. Hashed server-side. }
externalId:
type: string
description: |
Stable customer identifier (e.g. CRM user ID). Hashed
server-side for Meta and Google. Sent as plaintext to LinkedIn
(LinkedIn's Conversions API spec requires the raw value).
Maximum effective list size on LinkedIn is 1.
ipAddress: { type: string, description: Client IP address. Sent plaintext. }
userAgent: { type: string, description: Client user-agent string. Sent plaintext. }
country: { type: string, description: "ISO 3166-1 alpha-2 country code, e.g. 'us'." }
city: { type: string, description: "Meta advanced matching (ct). Plaintext city; normalized + SHA-256 hashed server-side. Meta only." }
state: { type: string, description: "Meta advanced matching (st). 2-letter ANSI for US; hashed server-side. Meta only." }
zip: { type: string, description: "Meta advanced matching (zp). US uses first 5 digits; hashed server-side. Meta only." }
dob: { type: string, description: "Meta advanced matching (db). YYYYMMDD; hashed server-side. Meta only." }
gender: { type: string, description: "Meta advanced matching (ge). 'f' or 'm'; hashed server-side. Meta only." }
leadId:
type: string
description: |
Meta lead ID from a Lead Ad submission, as a string. Required
for Conversion Leads CRM events: send it with
`actionSource: 'crm'` and
`platformData: { event_source: 'crm', lead_event_source: '<CRM name>' }`.
Forwarded unhashed to Meta's `user_data.lead_id`. Meta only.
clickIds:
type: object
description: Platform click identifiers captured from the originating ad click.
properties:
fbc: { type: string, description: Meta click ID (from fbclid URL param). }
fbp: { type: string, description: Meta browser ID (_fbp cookie). }
gclid: { type: string, description: Google click ID (from gclid URL param). }
gbraid: { type: string, description: Google iOS 14.5+ app attribution ID. }
wbraid: { type: string, description: Google iOS 14.5+ web-to-app attribution ID. }
li_fat_id:
type: string
description: |
LinkedIn first-party ad tracking click ID. Captured by
parsing `li_fat_id` from landing-page URLs after the
advertiser enables enhanced conversion tracking on the
LinkedIn Insight Tag. Sent to LinkedIn as the
LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID userId. Opaque
token, not hashed.
items:
type: array
description: Item-level detail for ecommerce events.
items:
type: object
properties:
id: { type: string }
name: { type: string }
price: { type: number }
quantity: { type: integer }
category: { type: string }
sourceUrl:
type: string
format: uri
description: URL where the conversion originated (used by Meta).
actionSource:
type: string
enum: [web, app, offline, crm, phone_call, system_generated]
description: 'Where the conversion happened. Used by Meta. Google also requires an event source internally; omitting this field sends OTHER to Google. Send an explicit value for accurate origin reporting.'
platformData:
type: object
additionalProperties: true
description: |
Escape hatch for platform-specific fields we haven't normalized.
On Meta, keys are shallow-merged into `custom_data` only: fields
Zernio already builds (`value`, `currency`, `contents`,
`num_items`) always win on collision, and `user_data` (hashed
match keys) is never touched. Use first-class fields (e.g.
`user.leadId`) for anything that must reach `user_data`.
ConversionDestination:
type: object
description: |
A discoverable conversion destination on an ad platform — a Meta pixel,
Google conversion action, or LinkedIn conversion rule. Returned by
`listConversionDestinations`, `getConversionDestination`,
`createConversionDestination`, and `updateConversionDestination`.
required: [id, name]
properties:
id:
type: string
description: |
Platform-native identifier. Pass back as `destinationId` on event
send and as the path segment on CRUD endpoints.
name: { type: string }
type:
type: string
description: |
Present when the platform locks the event type/category to the
destination (Google conversion actions, LinkedIn conversion rules).
Absent for Meta pixels (which accept any event name per request).
status:
type: string
enum: [active, inactive]
description: |
For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`).
adAccountId:
type: string
description: |
Set by adapters whose destinations are scoped to a specific ad
account (LinkedIn). Pass back on subsequent CRUD calls to
identify the parent ad account.
CtwaSingleResponse:
type: object
description: |
Response returned by `POST /v1/ads/ctwa` when the request used the
single-creative shape (top-level headline / body / imageUrl|video).
`adType` is the union discriminator.
required: [adType, ad, message]
properties:
adType: { type: string, enum: [single] }
ad: { type: object, description: The persisted Ad document. }
message: { type: string }
CtwaMultiResponse:
type: object
description: |
Response returned by `POST /v1/ads/ctwa` when the request used the
multi-creative shape (`creatives[]`). N persisted Ad documents share
the returned `platformCampaignId` and `platformAdSetId`. `adType` is
the union discriminator.
required: [adType, ads, platformCampaignId, platformAdSetId, message]
properties:
adType: { type: string, enum: [multi] }
ads:
type: array
description: |
The persisted Ad documents (one per creative), all sharing the same
`platformCampaignId` and `platformAdSetId`.
items: { type: object }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
message: { type: string }
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 }
TrackingTag:
type: object
description: |
A platform measurement tag — the thing you create, install on a
website, send events to, and target ads against. On Meta this is a
Pixel (`kind: pixel`). The shape is platform-neutral so other platforms
(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without
changing the contract; platform-specific fields are simply absent where
a platform has no equivalent. Returned by `listTrackingTags`,
`createTrackingTag`, `getTrackingTag`, and `updateTrackingTag`.
required: [id, name, platform, kind, status]
properties:
id:
type: string
description: 'Platform-native tag id. Meta: numeric pixel id, as a string.'
name: { type: string }
platform: { type: string, enum: [metaads] }
kind:
type: string
enum: [pixel, tag, insight_tag]
description: 'Platform-native flavor of the tag (Meta: `pixel`).'
status:
type: string
enum: [active, inactive]
description: '`inactive` when the platform reports the tag as broken/unavailable.'
code:
type: string
description: |
The base-code `<script>` snippet to install on the site. Meta only;
populated by `getTrackingTag`, omitted from the list view.
lastFiredTime:
type: [integer, "null"]
description: |
Unix seconds of the last event the tag received, or `null` if it
never fired. The practical "is it installed and working" signal.
isUnavailable:
type: boolean
description: Whether the tag is in a broken/unavailable state (Meta `is_unavailable`).
installed:
type: boolean
description: Convenience flag derived from `lastFiredTime` — has the tag ever fired.
creationTime:
type: integer
description: Unix seconds the tag was created.
ownerBusinessId:
type: [string, "null"]
description: |
Business Manager id that owns the tag, or `null` when the tag lives
on a personal (non-BM) ad account — such tags can't be shared with
other ad accounts.
ownerAdAccountId:
type: string
description: 'Ad account id (`act_...`) that owns the tag, when reported.'
SharedAdAccount:
type: object
description: An ad account a tracking tag is shared with (Meta `shared_accounts` edge).
required: [id]
properties:
id: { type: string, description: 'Ad account id, in `act_<digits>` form.' }
name: { type: string }
businessId: { type: string, description: "Business Manager id that owns the ad account, when reported." }
webhooks:
post.scheduled:
post:
operationId: onPostScheduled
summary: Post scheduled event
description: 'Fired whenever a post enters the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed or partial post retried, or a recycled clone created. Not fired when an already-scheduled post is edited or rescheduled.'
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.published:
post:
operationId: onPostPublished
summary: Post published event
description: Fired when a post is successfully published.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.failed:
post:
operationId: onPostFailed
summary: Post failed event
description: Fired when a post fails to publish on all target platforms.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.partial:
post:
operationId: onPostPartial
summary: Post partial event
description: Fired when a post publishes on some platforms and fails on others.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.cancelled:
post:
operationId: onPostCancelled
summary: Post cancelled event
description: Fired when a post publishing job is cancelled.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.recycled:
post:
operationId: onPostRecycled
summary: Post recycled event
description: 'Fired when a post is recycled (cloned and re-scheduled for publishing). The new clone also fires a post.scheduled event.'
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.platform.published:
post:
operationId: onPostPlatformPublished
summary: Post platform published event
description: |
Fired once per platform target inside a post as that platform finishes
publishing successfully. Does NOT wait for the post-level rollup —
consumers building incremental UIs get notified immediately, even
when other platforms on the same post are still processing.
The envelope event (`post.published` / `post.partial`) fires
separately AFTER all platforms have terminated.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.platform.failed:
post:
operationId: onPostPlatformFailed
summary: Post platform failed event
description: |
Fired once per platform target inside a post as that platform fails
permanently. Temporary/retryable failures do NOT fire this event —
only permanent ones, so retry loops stay quiet. The envelope event
(`post.failed` / `post.partial`) fires separately AFTER all
platforms have terminated.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.platform.deleted:
post:
operationId: onPostPlatformDeleted
summary: Post platform deleted event
description: |
Fired when Zernio's background sync detects that a platform target
published through Zernio was later deleted on the platform (e.g.
the user deleted the Instagram post natively). Detection is
poll-driven (~hourly), not real-time, and fires once per platform
target. `platform.deletedAt` carries the detection time. Detection
is listing-based: a false positive self-heals in Zernio's data when
the post reappears, but the event is not retracted. Coverage is
bounded to the posts the platform listing returns.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.tiktok.url_resolved:
post:
operationId: onPostTikTokUrlResolved
summary: TikTok post URL resolved event
description: |
Fired when an already-published TikTok platform entry gets its public
URL backfilled. TikTok exposes the numeric video id asynchronously
(often minutes after PUBLISH_COMPLETE), so the terminal events can
carry an empty `publishedUrl` for TikTok. This event delivers
`platform.publishedUrl` and the resolved `platform.platformPostId`
once available. At most once per platform target; never fires for
drafts or private posts (no public URL exists). Payload shape is
identical to `post.platform.published`.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
account.connected:
post:
operationId: onAccountConnected
summary: Account connected event
description: Fired when a social account is successfully connected.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAccountConnected'
responses:
'200':
description: Webhook received successfully
account.disconnected:
post:
operationId: onAccountDisconnected
summary: Account disconnected event
description: Fired when a connected social account becomes disconnected.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAccountDisconnected'
responses:
'200':
description: Webhook received successfully
account.ads.initial_sync_completed:
post:
operationId: onAccountAdsInitialSyncCompleted
summary: Ads initial sync completed event
description: |
Fired once per ads-enabled account when the initial sync (ad-account
discovery + 90-day historical ad backfill) completes. The `sync` block
reports whether the backfill succeeded and how many ads were synced.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAccountAdsInitialSyncCompleted'
responses:
'200':
description: Webhook received successfully
message.received:
post:
operationId: onMessageReceived
summary: Message received event
description: Fired when a new inbox message is received.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessage'
responses:
'200':
description: Webhook received successfully
conversation.started:
post:
operationId: onConversationStarted
summary: Conversation started event
description: |
Fired once when a new conversation begins between one of your connected accounts and a
contact, in either direction. Works across every DM platform (Instagram, Messenger/Facebook,
Telegram, WhatsApp, Twitter, Reddit, Bluesky). Naturally deduped — a given conversation
only fires this event the very first time it appears.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadConversationStarted'
responses:
'200':
description: Webhook received successfully
call.received:
post:
operationId: onCallReceived
summary: Call received event
description: |
Fired when a WhatsApp Business Call connects. For inbound (UIC) calls
the event fires at the moment our Telnyx trunk bridges the consumer
leg to the customer'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 arrives with `metadata.quotedMessageId` and mirroring
CRMs can thread it without a lookup.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageSent'
responses:
'200':
description: Webhook received successfully
message.edited:
post:
operationId: onMessageEdited
summary: Message edited event
description: |
Fired when a sender edits a previously-sent message. Supported on
Instagram, Facebook Messenger, and Telegram. The payload includes the
full editHistory so consumers can show prior versions.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageEdited'
responses:
'200':
description: Webhook received successfully
message.deleted:
post:
operationId: onMessageDeleted
summary: Message deleted event
description: |
Fired when a sender deletes (unsends) a message. Supported on Instagram
(incoming unsend) and WhatsApp 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.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadComment'
responses:
'200':
description: Webhook received successfully
review.new:
post:
operationId: onReviewNew
summary: Review new event
description: |
Fired when a new review is posted on a connected account. Currently supported
for Google Business Profile (real-time via Pub/Sub). Requires the Inbox add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadReviewNew'
responses:
'200':
description: Webhook received successfully
review.updated:
post:
operationId: onReviewUpdated
summary: Review updated event
description: |
Fired when a review changes: the reviewer edits their text or rating, or a
reply is added (via the API or directly through the Google Business dashboard).
Payload shape matches review.new. Requires the Inbox add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadReviewUpdated'
responses:
'200':
description: Webhook received successfully
post.external.created:
post:
operationId: onPostExternalCreated
summary: External post created event
description: |
Fired when Zernio's background sync detects a natively-authored post
(created outside Zernio, e.g. a Google Business Profile localPost made in
the Google UI) for the first time. Poll-driven (~hourly), not real-time.
`post.source` is always "external".
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
post.external.updated:
post:
operationId: onPostExternalUpdated
summary: External post updated event
description: |
Fired when a tracked native post's text or media changed on the platform.
Detected by comparing text/media structure and, where available, the
platform's own edit timestamp; a media-URL-only refresh does not fire this.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
post.external.deleted:
post:
operationId: onPostExternalDeleted
summary: External post deleted event
description: |
Fired when a tracked native post is detected as removed from the platform.
`post.deletedAt` carries the detection time. Coverage is bounded to the
most recent posts the platform listing returns.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
lead.received:
post:
operationId: onLeadReceived
summary: Lead received event
description: |
Fired when a new lead is submitted against a Meta Lead Gen (Instant) Form
and ingested via the Page `leadgen` webhook. `lead.fields` is the
question-key to answer map; `lead.formId` / `lead.adId` give provenance.
Requires the Ads add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadLead'
responses:
'200':
description: Webhook received successfully
ad.status_changed:
post:
operationId: onAdStatusChanged
summary: Ad status changed event
description: |
Fired when a campaign, ad set, or ad on a connected ad platform changes status.
Currently emitted only for Meta (`metaads`).
Subscribed to two Meta `ad_account` webhook fields:
- `in_process_ad_objects` - the ad object finished processing and exited
the `IN_PROCESS` state. `status.raw` carries Meta's `status_name`
(e.g. `ACTIVE`, `PAUSED`, `ARCHIVED`, `DELETED`).
- `with_issues_ad_objects` - the ad object entered the `WITH_ISSUES`
state. `status.raw` is set to `WITH_ISSUES` and the `error` block is
populated from Meta's `error_code` / `error_summary` / `error_message`.
`adObject.level` mirrors Meta's `level` and is one of `CAMPAIGN`,
`AD_SET`, or `AD`. Creative-level events are not forwarded.
Branch on `status.raw` to handle each transition; use `error.code` (when
present) as the stable discriminator — `error.summary` and `error.message`
are localized to the ad-account owner's Meta locale.
The `error` block is optional. It's present on most `WITH_ISSUES`
events but can be absent (Meta does not always include diagnostics),
and is never present on any other status. Always null-check `error`
before reading `error.code`.
**Fan-out:** matching is keyed on `adObject.platformAdAccountId`. When
multiple connected Zernio `metaads` accounts are linked to the same Meta
ad account, each receives its own delivery.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAdStatusChanged'
examples:
inProcessActive:
summary: in_process_ad_objects → ACTIVE (ad approved)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y3"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
displayName: Acme Ads
adObject:
level: AD
platformId: "120244894077860689"
platformAdAccountId: act_2129800524463520
status:
raw: ACTIVE
timestamp: "2026-05-05T15:25:27.944Z"
withIssuesError:
summary: with_issues_ad_objects → WITH_ISSUES (error populated)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y4"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
displayName: Acme Ads
adObject:
level: AD
platformId: "120244560555500043"
platformAdAccountId: act_587875401826220
status:
raw: WITH_ISSUES
error:
code: "2643001"
summary: Ad Processing Error
message: "Ad Processing Error: We are having trouble processing your request. To resolve the issue, please try to publish again."
timestamp: "2026-05-08T04:22:35.821Z"
inProcessWithIssues:
summary: in_process_ad_objects → WITH_ISSUES (no error block)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y5"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
adObject:
level: AD
platformId: "120244560555500043"
platformAdAccountId: act_587875401826220
status:
raw: WITH_ISSUES
timestamp: "2026-05-08T04:22:35.821Z"
responses:
'200':
description: Webhook received successfully
whatsapp.template.status_updated:
post:
operationId: onWhatsAppTemplateStatusUpdated
summary: WhatsApp template status updated event
description: |
Fired when Meta finishes (re)reviewing a WhatsApp Business template
attached to a connected WABA. Forwarded from Meta's
`message_template_status_update` webhook field on the WhatsApp
Business Account. Consumers branch on `template.status` (APPROVED,
REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION).
Meta does not include the previous status or the template's category
in this event.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadWhatsAppTemplateStatusUpdated'
responses:
'200':
description: Webhook received successfully
whatsapp.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`
even on coexistence numbers where the inbound referral omits it (this
webhook is the only surface that delivers it there); the clid is also
written back onto the conversation, so POST /v1/whatsapp/conversions
becomes usable for the thread.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.automatic_event] }
timestamp:
type: string
format: date-time
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.
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 }
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
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.
Twitter/X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length.
Returns counts and limits for all 15 supported platform variants.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
description: The post text to check
example: "Check out https://zernio.com for scheduling posts!"
responses:
"200":
description: Character counts per platform
content:
application/json:
schema:
type: object
properties:
text: { type: string }
platforms:
type: object
additionalProperties:
type: object
properties:
count: { type: integer, description: "Character count for this platform" }
limit: { type: integer, description: "Maximum allowed characters" }
valid: { type: boolean, description: "Whether the text is within the limit" }
example:
twitter: { count: 51, limit: 280, valid: true }
twitterPremium: { count: 51, limit: 25000, valid: true }
instagram: { count: 51, limit: 2200, valid: true }
bluesky: { count: 51, limit: 300, valid: true }
snapchat: { count: 51, limit: 160, valid: true }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/post:
post:
x-resource-group: "publishing"
operationId: validatePost
tags: [Validate]
summary: Validate post content
description: |
Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations.
Accepts the same body as POST /v1/posts. Does NOT validate accounts, process media, or track usage. Account lookups are limit-only: a twitter accountId is resolved, scoped to the caller, only to pick the 280 vs 25000 character limit. Missing, foreign, or invalid ids fall back to 280 and never error.
Returns errors for failures and warnings for near-limit content (>90% of character limit).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platforms]
properties:
content:
type: string
description: Post text content
example: "Check out this video!"
platforms:
type: array
description: Target platforms (same format as POST /v1/posts)
items:
type: object
required: [platform]
properties:
platform:
type: string
enum: [twitter, instagram, tiktok, youtube, facebook, linkedin, bluesky, threads, reddit, pinterest, telegram, snapchat, googlebusiness, discord, slack]
accountId: { type: string, description: 'Account to validate against. For twitter, resolves X Premium status to apply the 25000 character limit instead of 280.' }
customContent: { type: string }
platformSpecificData: { type: object }
customMedia:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
example:
- platform: youtube
- platform: twitter
mediaItems:
type: array
description: Root media items shared across platforms
items: { $ref: '#/components/schemas/MediaItem' }
responses:
"200":
description: Validation result
content:
application/json:
schema:
oneOf:
- type: object
description: Valid post
properties:
valid: { type: boolean }
message: { type: string, example: "No validation issues found." }
warnings:
type: array
items:
type: object
properties:
platform: { type: string }
warning: { type: string }
- type: object
description: Invalid post
properties:
valid: { type: boolean }
errors:
type: array
items:
type: object
properties:
platform: { type: string }
error: { type: string }
warnings:
type: array
items:
type: object
properties:
platform: { type: string }
warning: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/media:
post:
x-resource-group: "publishing"
operationId: validateMedia
tags: [Validate]
summary: Validate media URL
description: |
Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons.
Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection.
Platform limits are sourced from each platform's actual upload constraints.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: Public media URL to validate
example: "https://example.com/image.jpg"
responses:
"200":
description: Media validation result
content:
application/json:
schema:
type: object
properties:
valid: { type: boolean }
url: { type: string, format: uri }
error: { type: string, description: "Error message if valid is false" }
contentType: { type: string, example: "image/jpeg" }
size: { type: [integer, "null"], description: "File size in bytes" }
sizeFormatted: { type: string, example: "245 KB" }
type: { type: string, enum: [image, video, unknown] }
platformLimits:
type: object
description: Per-platform size limit comparison (only present when size and type are known)
additionalProperties:
type: object
properties:
limit: { type: integer, description: "Platform size limit in bytes" }
limitFormatted: { type: string }
withinLimit: { type: boolean }
example:
instagram: { limit: 8388608, limitFormatted: "8.0 MB", withinLimit: true }
twitter: { limit: 5242880, limitFormatted: "5.0 MB", withinLimit: true }
bluesky: { limit: 1000000, limitFormatted: "977 KB", withinLimit: true }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/subreddit:
get:
x-resource-group: "publishing"
operationId: validateSubreddit
tags: [Validate]
summary: Check subreddit existence
description: |
Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed).
When accountId is provided, uses authenticated Reddit OAuth API with automatic token refresh (recommended). Falls back to Reddit's public JSON API, which may be unreliable from server IPs. Returns exists: false for private, banned, or nonexistent subreddits.
security:
- bearerAuth: []
parameters:
- name: name
in: query
required: true
description: Subreddit name (with or without "r/" prefix)
schema:
type: string
example: "programming"
- name: accountId
in: query
description: Reddit social account ID for authenticated lookup (recommended for reliable results)
schema:
type: string
responses:
"200":
description: Subreddit lookup result
content:
application/json:
schema:
oneOf:
- type: object
description: Subreddit exists
properties:
exists: { type: boolean }
subreddit:
type: object
properties:
name: { type: string, example: "programming" }
title: { type: string, example: "programming" }
description: { type: string, example: "Computer Programming" }
subscribers: { type: integer, example: 6844284 }
isNSFW: { type: boolean }
type: { type: string, enum: [public, private, restricted], example: "public" }
allowImages: { type: boolean }
allowVideos: { type: boolean }
- type: object
description: Subreddit not found
properties:
exists: { type: boolean }
error: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
# ============================================
# Analytics
# ============================================
/v1/analytics:
get:
x-resource-group: "analytics"
operationId: getAnalytics
tags: [Analytics]
summary: Get post analytics
description: |
Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats.
Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days.
Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats.
LinkedIn personal accounts: Analytics are only available for posts published through Zernio. LinkedIn's API only returns metrics for posts authored by the authenticated user. Organization/company page analytics work for all posts.
parameters:
- name: postId
in: query
schema: { type: string }
description: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics.
- name: platform
in: query
schema: { type: string }
description: Filter by platform (default "all")
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID (default "all")
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID
- name: source
in: query
schema: { type: string, enum: [all, late, external], default: all }
description: "Filter by post source: late (posted via Zernio API), external (synced from platform), all (default)"
- name: fromDate
in: query
schema: { type: string, format: date }
description: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days.
- name: toDate
in: query
schema: { type: string, format: date }
description: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted.
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
description: Page size (default 50)
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
description: Page number (default 1)
- name: sortBy
in: query
schema: { type: string, enum: [date, engagement, impressions, reach, likes, comments, shares, saves, clicks, views, follows, ig_reels_avg_watch_time, ig_reels_video_view_total_time, reposts, reels_skip_rate], default: date }
description: 'Sort by date, engagement, or a specific metric. Instagram-only metrics (follows, reposts, reels_skip_rate, ig_reels_*) sort posts with no 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/youtube/channel-insights:
get:
x-resource-group: "analytics"
operationId: getYouTubeChannelInsights
tags: [Analytics]
summary: Get YouTube channel insights
description: |
Returns channel-scoped aggregate metrics from YouTube Analytics API v2. Saves you
from looping /v1/analytics/youtube/daily-views over every video when you only need
channel totals.
Response shape matches /v1/analytics/instagram/account-insights so the same client
handling works. Requires yt-analytics.readonly scope (412 with reauthorizeUrl if
missing). Data has a 2-3 day delay (endDate is clamped accordingly). Max 89 days,
defaults to last 30 days. Requires the Analytics add-on.
NOT exposed: impressions (Studio thumbnail impressions) and impressionsClickThroughRate.
YouTube Analytics API v2 does not expose these for any principal type, not channel
owners, not Partner Program channels, not content owners with CMS access. The only way
to get them is Studio CSV export. This is a Google-side limitation.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the YouTube account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to "views,estimatedMinutesWatched,subscribersGained,subscribersLost".
Live YouTube Analytics v2 metrics:
- views
- estimatedMinutesWatched
- averageViewDuration (ratio - weighted mean computed across days)
- subscribersGained
- subscribersLost
Zernio-synthesized from daily follower snapshots (cross-platform parity):
- followers_gained
- followers_lost
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to today. YouTube Analytics has a 2-3 day delay,
so the fetch is internally clamped to 3 days ago; any requested range extending
beyond that returns zero values for the tail days. The response's dateRange.until
field reflects your requested value.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals.
"time_series" returns per-day values in the "values" array.
responses:
'200':
description: Channel insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a YouTube account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
/v1/analytics/linkedin/org-aggregate-analytics:
get:
x-resource-group: "analytics"
operationId: getLinkedInOrgAggregateAnalytics
tags: [Analytics]
summary: Get LinkedIn org analytics
description: |
Returns aggregate analytics for a LinkedIn organization page. Parallel to
/v1/accounts/{id}/linkedin-aggregate-analytics (which handles personal accounts only).
Backed by LinkedIn's organizationalEntityShareStatistics,
organizationalEntityFollowerStatistics, and organizationPageStatistics endpoints.
Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
defaults to last 30 days. Requires the Analytics add-on.
Scope requirements: r_organization_social, r_organization_followers, and
r_organization_admin must all be present on the account. Accounts connected before
these scopes were included in the OAuth flow will return 412 with a reauth hint.
Enforced by this endpoint:
- Page-view metrics accept only metricType=total_value (LinkedIn omits per-day
segmentation even when the API is called with DAY granularity, so a time-series
response would be meaningless).
- Date range capped at 89 days.
LinkedIn-side platform limits (not re-enforced here, but worth knowing for larger
ranges in a future release):
- Follower stats: rolling 12-month window, end must be no later than 2 days ago.
- Share stats: rolling 12-month window.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the LinkedIn organization account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to
"impressions,clicks,engagement_rate,organic_followers_gained,followers_gained,followers_lost".
Share statistics (support both total_value and time_series):
- impressions
- unique_impressions
- clicks
- likes
- comments
- shares
- engagement_rate (0..1, LinkedIn-computed)
Follower-gain statistics (support total_value and time_series):
- organic_followers_gained (per-day organic gains for time_series; sum of organic gains over the range for total_value)
- paid_followers_gained (per-day paid gains for time_series; sum of paid gains over the range for total_value)
Page-view statistics (total_value ONLY - LinkedIn platform limit):
- page_views_total
- page_views_overview
- page_views_careers
- page_views_jobs
- page_views_life
Zernio-synthesized from daily follower snapshots:
- followers_gained
- followers_lost
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
responses:
'200':
description: Organization analytics data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: |
Bad request. Common cases:
- Account is a personal LinkedIn account, not organization (code personal_account_not_supported, use /v1/accounts/{id}/linkedin-aggregate-analytics instead)
- Invalid metric name, metricType, or date range
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'403':
description: |
Platform error. The authenticated member lacks the required
ADMINISTRATOR role on the organization. LinkedIn enforces admin-only
access for all three org statistics endpoints. The error envelope is
type platform_error, and the raw LinkedIn error is echoed in the
platformError field.
'404':
description: Account not found
'412':
description: Missing LinkedIn organization analytics scopes (r_organization_social + r_organization_followers + r_organization_admin)
/v1/analytics/tiktok/account-insights:
get:
x-resource-group: "analytics"
operationId: getTikTokAccountInsights
tags: [Analytics]
summary: Get TikTok account-level insights
description: |
Returns account-level TikTok insights from /v2/user/info/ (live) plus historical
time series joined from Zernio's daily snapshotter (AccountStats).
Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
defaults to last 30 days. Requires the Analytics add-on and the user.info.stats
scope on the account (412 if missing).
Scope intentionally narrow. TikTok's public API exposes only the four counter
metrics below. The deep metrics that live in TikTok Studio are NOT available on any
public TikTok API, even for Business accounts:
- profile_views
- account-level impressions / reach
- follower inflow / outflow breakdown
- video watch time, average watch time, full-watched rate
- impression_sources (FYP / Following / Hashtag / Search / Personal profile)
TikTok's Research API doesn't expose those fields either, and is restricted to
non-commercial academic use per TikTok's eligibility policy. There is no public
API workaround. Post-level metrics (views, likes, comments, shares per video) are
available via /v1/analytics?postId=... from TikTok's /v2/video/query/.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the TikTok account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to
"follower_count,likes_count,video_count,followers_gained,followers_lost".
Live from /v2/user/info/ (requires user.info.stats scope):
- follower_count (cumulative; time series joined from AccountStats)
- following_count (cumulative; time series joined from AccountStats.metadata)
- likes_count (cumulative; time series joined from AccountStats.metadata)
- video_count (cumulative; time series joined from AccountStats.metadata)
Zernio-synthesized:
- followers_gained (sum of positive daily follower deltas)
- followers_lost (sum of absolute negative daily deltas)
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns the latest cumulative counter value.
"time_series" returns daily values joined from AccountStats snapshots.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a TikTok account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
'412':
description: Missing user.info.stats scope
/v1/analytics/youtube/daily-views:
get:
x-resource-group: "analytics"
operationId: getYouTubeDailyViews
tags: [Analytics]
summary: Get YouTube daily views
description: |
Returns daily view counts for a YouTube video including views, watch time, and subscriber changes.
Requires yt-analytics.readonly scope (re-authorization may be needed). YouTube finalizes analytics
with a ~3-day delay; by default only finalized days are returned, and an explicit endDate can reach
into the delay window (see the endDate parameter). Max 90 days, defaults to last 30 days.
parameters:
- name: videoId
in: query
required: true
schema: { type: string }
description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio account ID for the YouTube account
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response), and days YouTube has not
processed yet are omitted from dailyViews.
responses:
'200':
description: Daily views breakdown
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDailyViewsResponse'
examples:
success:
summary: Successful response with daily views
value:
success: true
videoId: "dQw4w9WgXcQ"
durationSeconds: 213
dateRange:
startDate: "2025-01-01"
endDate: "2025-01-12"
totalViews: 12345
dailyViews:
- date: "2025-01-12"
views: 1234
estimatedMinutesWatched: 567.5
averageViewDuration: 45.2
averageViewPercentage: 21.2
subscribersGained: 10
subscribersLost: 2
likes: 89
comments: 12
shares: 5
- date: "2025-01-11"
views: 987
estimatedMinutesWatched: 432.1
averageViewDuration: 43.8
averageViewPercentage: 20.6
subscribersGained: 8
subscribersLost: 1
likes: 67
comments: 8
shares: 3
lastSyncedAt: "2025-01-15T12:00:00Z"
scopeStatus:
hasAnalyticsScope: true
'400':
description: Bad request (missing or invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
missingVideoId:
value:
error: "videoId is required"
invalidDate:
value:
error: "Invalid startDate format. Use YYYY-MM-DD."
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
examples:
scopeMissing:
summary: YouTube Analytics scope not granted
value:
success: false
error: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions."
code: "youtube_analytics_scope_missing"
scopeStatus:
hasAnalyticsScope: false
requiresReauthorization: true
reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..."
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
/v1/analytics/youtube/video-retention:
get:
x-resource-group: "analytics"
operationId: getYouTubeVideoRetention
tags: [Analytics]
summary: Get YouTube video retention curve
description: |
Returns the audience retention curve for a single YouTube video, plus the video's
duration for rendering the curve on a time axis. The curve has up to 100 points
(elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does
not support per-day retention breakdowns.
audienceWatchRatio is the absolute share of viewers watching at that point in the
video and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance
compares against videos of similar length (0 = worst, 0.5 = median, 1 = best).
YouTube returns an empty curve for videos with very few views or before analytics
processing completes (2-3 day delay).
Requires yt-analytics.readonly scope (re-authorization may be needed).
parameters:
- name: videoId
in: query
required: true
schema: { type: string }
description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio account ID for the YouTube account
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to the video's publish date (lifetime curve).
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response).
responses:
'200':
description: Audience retention curve
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeVideoRetentionResponse'
examples:
success:
summary: Successful response with retention curve
value:
success: true
accountId: "65f1a2b3c4d5e6f7a8b9c0d1"
videoId: "zF7Gp4jnDa0"
title: "what's your choice?"
publishedAt: "2026-05-20T12:00:00Z"
durationSeconds: 24
dateRange:
startDate: "2026-05-20"
endDate: "2026-06-09"
retentionCurve:
- elapsedVideoTimeRatio: 0.01
audienceWatchRatio: 1.6956
relativeRetentionPerformance: 0.73665
startedWatching: 280331
stoppedWatching: 5348
totalSegmentImpressions: 478466
- elapsedVideoTimeRatio: 0.02
audienceWatchRatio: 1.678
relativeRetentionPerformance: 0.73865
startedWatching: 385
stoppedWatching: 11790
totalSegmentImpressions: 473492
scopeStatus:
hasAnalyticsScope: true
'400':
description: Bad request (missing or invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Video not found, or it does not belong to this YouTube channel
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Video not found on this YouTube channel" }
type: { type: string, example: "not_found" }
code: { type: string, example: "video_not_found" }
param: { type: string, example: "videoId" }
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
examples:
scopeMissing:
summary: YouTube Analytics scope not granted
value:
success: false
error: "To access video retention analytics, please reconnect your YouTube account to grant the required permissions."
code: "youtube_analytics_scope_missing"
scopeStatus:
hasAnalyticsScope: false
requiresReauthorization: true
reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..."
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
/v1/analytics/facebook/page-insights:
get:
x-resource-group: "analytics"
operationId: getFacebookPageInsights
tags: [Analytics]
summary: Get Facebook Page insights
description: |
Returns page-level Facebook insights (media views, views, post engagements, video metrics,
follower counts). Response shape matches /v1/analytics/instagram/account-insights so the
same client handling works across platforms.
Metric names track the current (post-November 2025) Meta Graph API. The legacy
page_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated
by Meta on November 15, 2025 and are NOT accepted by this endpoint. Use the replacements
below. Because Meta did not provide direct adds/removes replacements, Zernio synthesizes
followers_gained / followers_lost from the daily follower snapshotter.
Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the connected Facebook Page.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to
"page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost".
Live Meta metrics (current names, post-Nov-2025):
- page_media_view (replaces deprecated page_impressions)
- page_views_total
- page_post_engagements
- page_video_views
- page_video_view_time
- page_follows (replaces deprecated page_fans)
Zernio-synthesized from daily follower snapshots (filling the Nov-2025 gap
left by the page_fan_adds / page_fan_removes deprecation):
- followers_gained
- followers_lost
Monetization (opt-in, not in the defaults):
- content_monetization_earnings
- monetization_approximate_earnings
Each monetization metric is fetched with its own separate Graph call, so requesting both
adds two calls. Values are approximate and Meta restates them after the fact.
content_monetization_earnings returns an object per day and always carries unit
"micro_amount" plus an ISO 4217 "currency". monetization_approximate_earnings returns a bare
number per day, so its unit is always "unspecified" and its "currency" is always null. The two
are on different scales and are not comparable to each other. Both keep their daily "values"
on every metricType and are never rescaled by Zernio.
Earnings here are Page-level daily buckets and "total" is their sum. Meta does not
document whether a bucket carries that day's earnings or a running total, and every
Page measured so far earned exactly 0, so reconcile "total" against the Page's own Meta
export before relying on it; the daily "values" are always returned for that purpose.
Per-post lifetime earnings are served by GET /v1/analytics/facebook/post-earnings.
A Page that is not enrolled in monetization, or that earned nothing, returns normal daily
buckets of 0 in "metrics": Meta does not distinguish the two, so a 0 total here does NOT mean
the Page is enrolled. "unavailableMetrics" covers the narrower case where Meta returned no
bucket for the metric at all ("no_data") or rejected the request outright, and the metric is
then omitted from "metrics" rather than reported as 0.
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals only.
"time_series" returns daily values in the "values" array.
responses:
'200':
description: Page insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series with computed follower deltas
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-03-01", until: "2026-03-22" }
metricType: "time_series"
metrics:
page_media_view:
total: 125000
values:
- { date: "2026-03-01", value: 5400 }
- { date: "2026-03-02", value: 4820 }
followers_gained:
total: 142
values:
- { date: "2026-03-01", value: 7 }
- { date: "2026-03-02", value: 5 }
followers_lost:
total: 23
values:
- { date: "2026-03-01", value: 1 }
- { date: "2026-03-02", value: 0 }
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
monetization:
summary: 'Both monetization metrics, each at its own scale'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-06-01", until: "2026-06-30" }
metricType: "total_value"
metrics:
page_media_view:
total: 125000
content_monetization_earnings:
total: 1234000000
unit: "micro_amount"
currency: "USD"
values:
- { date: "2026-06-01", value: 634000000 }
- { date: "2026-06-02", value: 600000000 }
monetization_approximate_earnings:
total: 1234
unit: "unspecified"
currency: null
values:
- { date: "2026-06-01", value: 700 }
- { date: "2026-06-02", value: 534 }
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
monetizationUnavailable:
summary: 'A monetization metric Meta returned no bucket for'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-06-01", until: "2026-06-30" }
metricType: "total_value"
metrics:
page_media_view:
total: 125000
unavailableMetrics:
- metric: "content_monetization_earnings"
reason: "no_data"
message: "Meta returned no data for this monetization metric."
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
'400':
description: |
Bad request. Common cases:
- Requested a deprecated metric (page_impressions, page_fans, page_fan_adds, page_fan_removes) - use current names instead
- Account has no Page selected (metadata.pageAccessToken missing)
- Invalid accountId / metrics / metricType / date range
- Account is not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/facebook/post-earnings:
get:
x-resource-group: "analytics"
operationId: getFacebookPostEarnings
tags: [Analytics]
summary: Get Facebook post monetization earnings
description: |
Returns lifetime monetization earnings for ONE Facebook post, read live from Meta on every
request. Requires the Analytics add-on.
Earnings are CUMULATIVE since the post was published, not earnings within a date range, so
this endpoint takes no since/until and the totals must not be summed across dates or across
posts. Page-level daily earnings live on /v1/analytics/facebook/page-insights.
A post on a Page that is not enrolled in monetization, or that earned nothing, returns
"total": 0 rather than an error: Meta does not distinguish the two. A metric Meta returned no
bucket for at all is reported in "unavailableMetrics" and omitted from "metrics", never as a 0.
Amounts are the platform's raw numbers in the stated "unit" and are never rescaled by Zernio.
Breakdown dimensions are not exposed and a "breakdown" param is rejected with 400. So are
"since", "until", "period", and "metricType": scoping this endpoint to a window is not
possible, and silently returning the lifetime total for one would let a caller sum a year of
weekly requests into a figure ~52x the post's real earnings.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the connected Facebook Page.
- name: postId
in: query
required: true
schema: { type: string }
description: |
The platform post ID, exactly as returned in platformAnalytics[].platformPostId by
/v1/analytics: "{pageId}_{postId}", or the bare video ID for Reels.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of monetization metrics. Defaults to both:
- content_monetization_earnings
- monetization_approximate_earnings
content_monetization_earnings always carries unit "micro_amount" plus an ISO 4217
"currency". monetization_approximate_earnings is always a bare number, so its unit is
"unspecified" and its "currency" is null. The two are on different scales and are not
comparable to each other. Any other metric name is rejected with 400.
responses:
'200':
description: Lifetime per-post monetization earnings
content:
application/json:
schema:
$ref: '#/components/schemas/FacebookPostEarningsResponse'
examples:
served:
summary: 'Both metrics served, each at its own scale'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
postId: "123456789_987654321"
platform: "facebook"
period: "lifetime"
metrics:
content_monetization_earnings:
total: 1234000000
unit: "micro_amount"
currency: "USD"
monetization_approximate_earnings:
total: 1234
unit: "unspecified"
currency: null
dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
partiallyUnavailable:
summary: 'One metric served, one with no bucket returned by Meta'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
postId: "123456789_987654321"
platform: "facebook"
period: "lifetime"
metrics:
monetization_approximate_earnings:
total: 0
unit: "unspecified"
currency: null
unavailableMetrics:
- metric: "content_monetization_earnings"
reason: "no_data"
message: "Meta returned no data for this monetization metric."
dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
'400':
description: |
Bad request. Common cases:
- Invalid accountId format, or a metric name that is not a monetization metric
- A "breakdown" param was supplied (breakdown dimensions are not exposed)
- A "since", "until", "period", or "metricType" param was supplied (this endpoint returns a lifetime total and takes no date range)
- Account has no Page access token (metadata.pageAccessToken missing)
- Account is not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/instagram/account-insights:
get:
x-resource-group: "analytics"
operationId: getInstagramAccountInsights
tags: [Analytics]
summary: Get Instagram insights
description: |
Returns account-level Instagram insights such as reach, views, accounts engaged, and total interactions.
These metrics reflect the entire account's performance across all content surfaces (feed, stories, explore, profile),
and are fundamentally different from post-level metrics. Data may be delayed up to 48 hours.
Max 90 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to "reach,views,accounts_engaged,total_interactions".
Valid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares,
replies, reposts, follows_and_unfollows, profile_links_taps.
Note: only "reach" supports metricType=time_series. All other metrics (including
follows_and_unfollows) are total_value only. This is an Instagram Graph API limitation,
not a Zernio limitation - the IG API does not return time-series data for these metrics.
For a daily running follower count, use /v1/analytics/instagram/follower-history instead.
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals and supports breakdowns.
"time_series" returns daily values but only works with the "reach" metric.
- name: breakdown
in: query
schema: { type: string }
description: |
Breakdown dimension (only valid with metricType=total_value).
Valid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series response with daily values
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "time_series"
metrics:
reach:
total: 12500
values:
- date: "2026-03-01"
value: 420
- date: "2026-03-02"
value: 385
views:
total: 45000
values:
- date: "2026-03-01"
value: 1520
- date: "2026-03-02"
value: 1380
dataDelay: "Data may be delayed up to 48 hours"
totalValueWithBreakdown:
summary: Total value response with media type breakdown
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "total_value"
breakdown: "media_product_type"
metrics:
reach:
total: 12500
breakdowns:
- dimension: "FEED"
value: 5000
- dimension: "REELS"
value: 7500
dataDelay: "Data may be delayed up to 48 hours"
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidMetric:
value:
error: "Invalid metrics: impressions"
validMetrics: ["accounts_engaged", "comments", "follows_and_unfollows", "likes", "profile_links_taps", "reach", "replies", "reposts", "saves", "shares", "total_interactions", "views"]
breakdownWithTimeSeries:
value:
error: "Breakdowns are only supported with metricType=total_value"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/instagram/follower-history:
get:
x-resource-group: "analytics"
operationId: getInstagramFollowerHistory
tags: [Analytics]
summary: Get Instagram follower history
description: |
Returns a daily running Instagram follower count time series, served from Zernio's
cross-platform daily snapshotter. Exists because Meta removed follower_count from
the /insights endpoint in Graph API v22+ and never exposed a historical daily series
via any public API.
Response envelope matches /v1/analytics/instagram/account-insights so the same client
handling works. Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to "follower_count,followers_gained,followers_lost".
- follower_count : per-day raw follower count
- followers_gained : sum of positive daily deltas
- followers_lost : sum of absolute negative daily deltas
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns aggregated totals (latest for follower_count, sum for gained/lost).
"time_series" returns per-day values in the "values" array.
responses:
'200':
description: Follower history data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / date range, or account is not an Instagram account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/instagram/demographics:
get:
x-resource-group: "analytics"
operationId: getInstagramDemographics
tags: [Analytics]
summary: Get Instagram demographics
description: |
Returns audience demographic insights for an Instagram account, broken down by age, city, country, and/or gender.
Requires at least 100 followers. Returns top 45 entries per dimension.
Data may be delayed up to 48 hours. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metric
in: query
schema:
type: string
enum: [follower_demographics, engaged_audience_demographics]
default: follower_demographics
description: |
"follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers.
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, city, country, gender.
Defaults to all four if omitted.
- name: timeframe
in: query
schema:
type: string
enum: [this_week, this_month]
default: this_month
description: |
Time period for demographic data. Defaults to "this_month".
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramDemographicsResponse'
examples:
allBreakdowns:
summary: All four demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
metric: "follower_demographics"
timeframe: "last_30_days"
demographics:
age:
- dimension: "25-34"
value: 4500
- dimension: "18-24"
value: 3200
gender:
- dimension: "M"
value: 3000
- dimension: "F"
value: 4800
city:
- dimension: "New York, New York"
value: 800
- dimension: "Los Angeles, California"
value: 650
country:
- dimension: "US"
value: 5000
- dimension: "GB"
value: 1200
note: "Demographics show top 45 entries per dimension. Requires 100+ followers."
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidBreakdown:
value:
error: "Invalid breakdowns: location"
validBreakdowns: ["age", "city", "country", "gender"]
insufficientFollowers:
value:
success: false
error: "Demographic insights require at least 100 followers."
code: "instagram_insufficient_followers"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/youtube/demographics:
get:
x-resource-group: "analytics"
operationId: getYouTubeDemographics
tags: [Analytics]
summary: Get YouTube demographics
description: |
Returns audience demographic insights for a YouTube channel, broken down by age, gender, and/or country.
Pass videoId to get the audience profile of a single video instead of the whole channel.
Age and gender values are viewer percentages (0-100). Country values are view counts.
Data is based on signed-in viewers only, with a 2-3 day delay. YouTube suppresses demographics
for videos with too few signed-in views, so low-traffic videos can return empty breakdowns.
Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the YouTube account
- name: videoId
in: query
schema: { type: string }
description: |
YouTube video ID. When provided, demographics are scoped to this single video
(must belong to the connected channel; otherwise 404 video_not_found).
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, gender, country.
Defaults to all three if omitted.
- name: startDate
in: query
schema: { type: string, format: date }
description: |
Start date in YYYY-MM-DD format. Defaults to 90 days ago, or to the video's
publish date (lifetime) when videoId is provided.
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response).
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDemographicsResponse'
examples:
allBreakdowns:
summary: All three demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "youtube"
demographics:
age:
- dimension: "25-34"
value: 28.5
- dimension: "18-24"
value: 22.1
gender:
- dimension: "male"
value: 62.3
- dimension: "female"
value: 35.8
country:
- dimension: "US"
value: 12000
- dimension: "GB"
value: 3500
dateRange:
startDate: "2026-01-01"
endDate: "2026-03-31"
note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
singleVideo:
summary: Demographics scoped to one video (videoId provided)
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "youtube"
videoId: "dQw4w9WgXcQ"
title: "Product launch teaser"
publishedAt: "2026-06-01T09:00:00.000Z"
demographics:
age:
- dimension: "25-34"
value: 41.2
gender:
- dimension: "male"
value: 75.0
- dimension: "female"
value: 25.0
country:
- dimension: "MX"
value: 394
- dimension: "AR"
value: 181
dateRange:
startDate: "2026-06-01"
endDate: "2026-07-13"
note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
'400':
description: Bad request (invalid parameters or not a YouTube account)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: 'Account not found, or the video does not exist / does not belong to this YouTube channel'
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
'412':
description: YouTube Analytics scope not granted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
code: { type: string, example: "youtube_analytics_scope_missing" }
scopeStatus:
type: object
properties:
hasAnalyticsScope: { type: boolean, example: false }
requiresReauthorization: { type: boolean, example: true }
reauthorizeUrl: { type: string }
/v1/analytics/daily-metrics:
get:
x-resource-group: "analytics"
operationId: getDailyMetrics
tags: [Analytics]
summary: Get daily aggregated metrics
description: |
Returns daily aggregated analytics metrics and a per-platform breakdown.
Each day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views).
Defaults to the last 180 days. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Inclusive start date (ISO 8601). Defaults to 180 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: Inclusive end date (ISO 8601). Defaults to now.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
- name: attribution
in: query
schema:
type: string
enum: [publish, received]
default: publish
description: |
How each post's engagement is attributed to a day.
"publish" (default) sums each post's lifetime total on its publish date.
"received" buckets the per-day increase in engagement by the day it actually arrived (engagement-over-time), so engagement on older posts appears on the day it was gained rather than the post's publish date.
responses:
'200':
description: Daily metrics and platform breakdown
content:
application/json:
schema:
type: object
properties:
dailyData:
type: array
items:
type: object
properties:
date: { type: string, example: "2025-12-01" }
postCount: { type: integer, example: 3 }
platforms:
type: object
additionalProperties: { type: integer }
example: { instagram: 2, twitter: 1 }
metrics:
type: object
properties:
impressions: { type: integer }
reach: { type: integer }
likes: { type: integer }
comments: { type: integer }
shares: { type: integer }
saves: { type: integer }
clicks: { type: integer }
views: { type: integer }
platformBreakdown:
type: array
items:
type: object
properties:
platform: { type: string, example: "instagram" }
postCount: { type: integer, example: 142 }
impressions: { type: integer }
reach: { type: integer }
likes: { type: integer }
comments: { type: integer }
shares: { type: integer }
saves: { type: integer }
clicks: { type: integer }
views: { type: integer }
examples:
success:
value:
dailyData:
- date: "2025-12-01"
postCount: 3
platforms: { instagram: 2, twitter: 1 }
metrics:
impressions: 4520
reach: 3200
likes: 312
comments: 45
shares: 28
saves: 67
clicks: 89
views: 1560
platformBreakdown:
- platform: "instagram"
postCount: 142
impressions: 89400
reach: 62100
likes: 8930
comments: 1204
shares: 567
saves: 2103
clicks: 3402
views: 45200
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
/v1/analytics/best-time:
get:
x-resource-group: "analytics"
operationId: getBestTimeToPost
tags: [Analytics]
summary: Get best times to post
description: |
Returns the best times to post based on historical engagement data.
Groups all published posts by day of week and hour (UTC), calculating average engagement per slot.
Use this to auto-schedule posts at optimal times. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Best time slots
content:
application/json:
schema:
type: object
properties:
slots:
type: array
items:
type: object
properties:
day_of_week: { type: integer, description: "0=Monday, 6=Sunday", minimum: 0, maximum: 6 }
hour: { type: integer, description: "Hour in UTC (0-23)", minimum: 0, maximum: 23 }
avg_engagement: { type: number, description: "Average engagement (likes + comments + shares + saves)" }
post_count: { type: integer, description: "Number of posts in this slot" }
examples:
success:
value:
slots:
- day_of_week: 2
hour: 18
avg_engagement: 510.3
post_count: 15
- day_of_week: 0
hour: 9
avg_engagement: 342.5
post_count: 12
- day_of_week: 4
hour: 12
avg_engagement: 289.1
post_count: 8
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/content-decay:
get:
x-resource-group: "analytics"
operationId: getContentDecay
tags: [Analytics]
summary: Get content performance decay
description: |
Returns how engagement accumulates over time after a post is published.
Each bucket shows what percentage of the post's total engagement had been reached by that time window.
Useful for understanding content lifespan (e.g. "posts reach 78% of total engagement within 24 hours").
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Content decay buckets
content:
application/json:
schema:
type: object
properties:
buckets:
type: array
items:
type: object
properties:
bucket_order: { type: integer, description: "Sort order (0 = earliest, 6 = latest)" }
bucket_label: { type: string, description: "Human-readable label" }
avg_pct_of_final: { type: number, description: "Average % of final engagement reached (0-100)" }
post_count: { type: integer, description: "Number of posts with data in this bucket" }
examples:
success:
value:
buckets:
- bucket_order: 0
bucket_label: "0-6h"
avg_pct_of_final: 45.2
post_count: 89
- bucket_order: 1
bucket_label: "6-12h"
avg_pct_of_final: 18.7
post_count: 89
- bucket_order: 2
bucket_label: "12-24h"
avg_pct_of_final: 14.1
post_count: 85
- bucket_order: 3
bucket_label: "1-2d"
avg_pct_of_final: 9.3
post_count: 82
- bucket_order: 4
bucket_label: "2-7d"
avg_pct_of_final: 8.1
post_count: 78
- bucket_order: 5
bucket_label: "7-30d"
avg_pct_of_final: 3.8
post_count: 64
- bucket_order: 6
bucket_label: "30d+"
avg_pct_of_final: 0.8
post_count: 41
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/posting-frequency:
get:
x-resource-group: "analytics"
operationId: getPostingFrequency
tags: [Analytics]
summary: Get frequency vs engagement
description: |
Returns the correlation between posting frequency (posts per week) and engagement rate, broken down by platform.
Helps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination
with the average engagement rate observed across all weeks matching that frequency.
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Posting frequency data
content:
application/json:
schema:
type: object
properties:
frequency:
type: array
items:
type: object
properties:
platform: { type: string, example: "instagram" }
posts_per_week: { type: integer, description: "Number of posts published that week" }
avg_engagement_rate: { type: number, description: "Average engagement rate as percentage (0-100)" }
avg_engagement: { type: number, description: "Average raw engagement (likes+comments+shares+saves)" }
weeks_count: { type: integer, description: "Number of calendar weeks observed at this frequency" }
examples:
success:
value:
frequency:
- platform: "instagram"
posts_per_week: 2
avg_engagement_rate: 44.4
avg_engagement: 512
weeks_count: 18
- platform: "instagram"
posts_per_week: 4
avg_engagement_rate: 5.9
avg_engagement: 203
weeks_count: 6
- platform: "facebook"
posts_per_week: 3
avg_engagement_rate: 12.5
avg_engagement: 87
weeks_count: 10
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/post-timeline:
get:
x-resource-group: "analytics"
operationId: getPostTimeline
tags: [Analytics]
summary: Get post analytics timeline
description: |
Returns a daily timeline of analytics metrics for a specific post, showing how impressions, likes,
and other metrics evolved day-by-day since publishing. Each row represents one day of data per platform.
For multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on.
parameters:
- name: postId
in: query
required: true
schema: { type: string }
description: |
The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID.
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Start of date range (ISO 8601). Defaults to 90 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: End of date range (ISO 8601). Defaults to now.
responses:
'200':
description: Daily analytics timeline
content:
application/json:
schema:
type: object
properties:
postId:
type: string
description: The postId that was requested
timeline:
type: array
items:
type: object
properties:
date: { type: string, format: date, description: "Date in YYYY-MM-DD format" }
platform: { type: string, description: "Platform name (e.g. instagram, tiktok)" }
platformPostId: { type: string, description: "Platform-specific post ID" }
impressions: { type: integer, description: "Total impressions on this date" }
reach: { type: integer, description: "Total reach on this date" }
likes: { type: integer, description: "Total likes on this date" }
comments: { type: integer, description: "Total comments on this date" }
shares: { type: integer, description: "Total shares on this date" }
saves: { type: integer, description: "Total saves on this date" }
clicks: { type: integer, description: "Total clicks on this date" }
views: { type: integer, description: "Total views on this date" }
examples:
single_platform:
summary: Single-platform post timeline
value:
postId: "6507a1b2c3d4e5f6a7b8c9d0"
timeline:
- date: "2025-01-15"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 1200
reach: 980
likes: 45
comments: 3
shares: 12
saves: 8
clicks: 25
views: 0
- date: "2025-01-16"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 2400
reach: 1850
likes: 92
comments: 7
shares: 21
saves: 15
clicks: 48
views: 0
'400':
description: Missing required postId parameter
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Missing required parameter: postId" }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Forbidden (post belongs to another user or API key scope violation)
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Forbidden" }
'404':
description: Post not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Post not found" }
/v1/analytics/googlebusiness/performance:
get:
x-resource-group: "analytics"
operationId: getGoogleBusinessPerformance
tags: [Analytics]
summary: Get GBP performance metrics
description: |
Returns daily performance metrics for a Google Business Profile location.
Metrics include impressions (Maps/Search, desktop/mobile), website clicks,
call clicks, direction requests, conversations, bookings, and food orders.
Data may be delayed 2-3 days. Max 18 months of historical data.
Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated metric names. Defaults to all available metrics.
Valid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,
BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH,
BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS,
BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back.
- name: endDate
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
responses:
'200':
description: Performance metrics with daily time series
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
dateRange:
type: object
properties:
startDate: { type: string, format: date, example: "2026-03-01" }
endDate: { type: string, format: date, example: "2026-03-31" }
metrics:
type: object
description: Each key is a metric name containing total and daily values.
additionalProperties:
type: object
properties:
total: { type: integer, description: "Sum of all daily values in the range" }
values:
type: array
items:
type: object
properties:
date: { type: string, format: date }
value: { type: integer }
dataDelay: { type: string, example: "Data may be delayed 2-3 days" }
examples:
performance_data:
summary: Performance metrics for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
dateRange:
startDate: "2026-03-01"
endDate: "2026-03-31"
metrics:
WEBSITE_CLICKS:
total: 42
values:
- date: "2026-03-01"
value: 3
- date: "2026-03-02"
value: 1
CALL_CLICKS:
total: 7
values:
- date: "2026-03-01"
value: 1
BUSINESS_IMPRESSIONS_MOBILE_SEARCH:
total: 156
values:
- date: "2026-03-01"
value: 8
dataDelay: "Data may be delayed 2-3 days"
'400':
description: Invalid parameters
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Invalid metrics: INVALID_METRIC" }
validMetrics:
type: array
items: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
/v1/analytics/googlebusiness/search-keywords:
get:
x-resource-group: "analytics"
operationId: getGoogleBusinessSearchKeywords
tags: [Analytics]
summary: Get GBP search keywords
description: |
Returns search keywords that triggered impressions for a Google Business Profile location.
Data is aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded.
Max 18 months of historical data. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: startMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: Start month (YYYY-MM). Defaults to 3 months ago.
- name: endMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: End month (YYYY-MM). Defaults to current month.
responses:
'200':
description: Search keywords with impression counts
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
monthRange:
type: object
properties:
startMonth: { type: string, example: "2026-01" }
endMonth: { type: string, example: "2026-03" }
keywords:
type: array
items:
type: object
properties:
keyword: { type: string, example: "restaurant near me" }
impressions: { type: integer, example: 245 }
note: { type: string, example: "Keywords below a minimum impression threshold are excluded by Google" }
examples:
keywords_data:
summary: Search keywords for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
monthRange:
startMonth: "2026-01"
endMonth: "2026-03"
keywords:
- keyword: "restaurant near me"
impressions: 245
- keyword: "best tapas barcelona"
impressions: 89
- keyword: "zernio"
impressions: 34
note: "Keywords below a minimum impression threshold are excluded by Google"
'400':
description: Invalid parameters
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Invalid startMonth format. Use YYYY-MM." }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
# ─── Inbox analytics ───────────────────────────────────────────────────
# Same `[Analytics]` tag as the posting endpoints above so the rendered
# docs keep posting + inbox analytics centralized in one group. Every
# endpoint here requires `fromDate` (YYYY-MM-DD) and caps the date span
# at 365 days.
/v1/analytics/inbox/volume:
get:
x-resource-group: "analytics"
operationId: getInboxVolume
tags: [Inbox Analytics]
summary: Get inbox messaging volume
description: |
Daily inbox messaging volume + breakdowns. Folds the raw messaging
events into three projections so the client can render the volume
chart, KPI strip, and per-platform stacked bar from a single call.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
description: Inclusive lower bound (YYYY-MM-DD). Required.
- name: toDate
in: query
schema: { type: string, format: date }
description: Inclusive upper bound (YYYY-MM-DD). Defaults to today.
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
description: Filter by single platform (facebook, instagram, twitter, etc.).
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
description: "Filter by metadata.source lineage (human, workflow, sequence, broadcast, comment_automation, api, contact, platform)."
responses:
'200':
description: Volume breakdown
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
uniqueConversations: { type: integer }
timeseries:
type: array
items:
type: object
properties:
date: { type: string, format: date }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
byPlatform:
type: array
items:
type: object
properties:
platform: { type: string }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/heatmap:
get:
x-resource-group: "analytics"
operationId: getInboxHeatmap
tags: [Inbox Analytics]
summary: Get day × hour heatmap
description: |
Day-of-week × hour-of-day breakdown of inbox messages. Buckets are
sparse — only cells with at least one event are returned; clients
zero-fill the rest to render the full 7×24 grid. The `dow` field
follows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 =
Sunday). Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: action
in: query
schema: { type: string, enum: [message.received, message.sent, message.read, all] }
description: Narrow to a single event type. "all" or omitted means no filter.
responses:
'200':
description: Heatmap buckets
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
buckets:
type: array
items:
type: object
properties:
dow: { type: integer, minimum: 1, maximum: 7, description: "1 = Monday, 7 = Sunday" }
hour: { type: integer, minimum: 0, maximum: 23 }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/source-breakdown:
get:
x-resource-group: "analytics"
operationId: getInboxSourceBreakdown
tags: [Inbox Analytics]
summary: Get inbox source breakdown
description: |
Breakdown of inbox messages by their lineage source (the
`metadata.source` field set at ingest time: human / workflow /
sequence / broadcast / comment_automation / api / contact /
platform). Each source row also carries a per-platform sub-split.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
responses:
'200':
description: Source breakdown
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
sources:
type: array
items:
type: object
properties:
source: { type: string }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
byPlatform:
type: array
items:
type: object
properties:
platform: { type: string }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/response-time:
get:
x-resource-group: "analytics"
operationId: getInboxResponseTime
tags: [Inbox Analytics]
summary: Get inbox response-time stats
description: |
Time-to-first-response stats. Pairs each received message with the
next sent message in the same conversation and reports the delta
as both summary statistics and a fixed-bucket histogram suited
for the analytics page's TTR chart.
`sampleSize` reflects only conversations that received AND got a
reply in the window — received-but-never-answered conversations
are excluded. Compare against /v1/analytics/inbox/volume's
`summary.received` to compute reply rate.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
responses:
'200':
description: Response-time summary + histogram
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
sampleSize: { type: integer }
medianSeconds: { type: integer }
p90Seconds: { type: integer }
p99Seconds: { type: integer }
meanSeconds: { type: integer }
fastestSeconds: { type: integer }
slowestSeconds: { type: integer }
histogram:
type: array
items:
type: object
properties:
bucket: { type: string, description: "Human label (0-1m, 1-5m, 5-15m, 15-60m, 1-4h, 4-24h, 1d+)" }
lowerSeconds: { type: integer }
upperSeconds: { type: [integer, "null"], description: "null on the open-ended last bucket" }
count: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/top-accounts:
get:
x-resource-group: "analytics"
operationId: getInboxTopAccounts
tags: [Inbox Analytics]
summary: Get top accounts by inbox volume
description: |
Leaderboard of social accounts by inbox message volume. Decorates
each row with display labels from the live SocialAccount record
(so the UI shows username + displayName, not just an ID). Accounts
that no longer map to a SocialAccount surface as "(disconnected)"
so the row stays visible. Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
description: "Cap on returned rows. Lower than the posting listing's 100 because each row triggers a SocialAccount Mongo lookup."
responses:
'200':
description: Top accounts leaderboard
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
accounts:
type: array
items:
type: object
properties:
accountId: { type: string }
platform: { type: string }
displayName: { type: string, description: "(disconnected) when the SocialAccount no longer exists" }
username: { type: string }
received: { type: integer }
sent: { type: integer }
total: { type: integer }
conversations: { type: integer }
medianResponseSeconds: { type: integer }
repliedCount: { type: integer, description: "Distinguishes 'instant replies' from 'no replies at all' so a zero medianResponseSeconds with repliedCount=0 renders as '—' instead of '0s'" }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/conversations:
get:
x-resource-group: "messages"
operationId: listInboxConversationAnalytics
tags: [Inbox Analytics]
summary: List conversation analytics
description: |
Per-conversation listing with per-row totals + first/last message
timestamps. The inbox analog of GET /v1/analytics (posts listing) —
same filter shape, same pagination, same sort/order semantics.
Use as the entry point for the per-conversation analytics drawer
at /v1/analytics/inbox/conversations/{conversationId}.
Rows are enriched with the conversation's participant info
(`participantName`, `participantUsername`, `participantPicture`)
and last-message preview by joining the Conversation document
scoped to the caller's team. Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: sortBy
in: query
schema: { type: string, enum: [lastMessageAt, firstMessageAt, totalMessages, received, sent, read, failed], default: lastMessageAt }
- name: order
in: query
schema: { type: string, enum: [asc, desc], default: desc }
responses:
'200':
description: Paginated conversation analytics list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
items:
type: array
items:
type: object
properties:
conversationId: { type: string, description: "The platformConversationId (the same identity used by metadata.conversationId)" }
mongoId: { type: [string, "null"], description: "The Conversation document _id, when a matching doc exists" }
accountId: { type: string }
platform: { type: string }
participantName: { type: [string, "null"] }
participantUsername: { type: [string, "null"] }
participantPicture: { type: [string, "null"] }
lastMessage: { type: [string, "null"], description: "Cached preview from the Conversation doc" }
totalMessages: { type: integer }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
firstMessageAt: { type: string, format: date-time }
lastMessageAt: { type: string, format: date-time }
pagination:
type: object
properties:
page: { type: integer }
limit: { type: integer }
total: { type: integer }
totalPages: { type: integer }
hasMore: { type: boolean }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/conversations/{conversationId}:
get:
x-resource-group: "messages"
operationId: getInboxConversationAnalytics
tags: [Inbox Analytics]
summary: Get conversation analytics
description: |
Per-conversation inbox analytics. The inbox analog of
/v1/analytics/post-timeline — one conversation, daily totals,
source mix.
The {conversationId} path param accepts EITHER the Mongo `_id` of
the Conversation document OR its `platformConversationId` (the
same identity used by metadata.conversationId at ingest time).
Ownership is verified in MongoDB against the caller's team
before the Tinybird query fires.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: Mongo _id or platformConversationId.
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
responses:
'200':
description: Per-conversation analytics
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
conversationId: { type: string, description: "The platformConversationId" }
mongoId: { type: string }
platform: { type: [string, "null"] }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
totalMessages: { type: integer }
firstMessageAt: { type: [string, "null"], format: date-time }
lastMessageAt: { type: [string, "null"], format: date-time }
timeseries:
type: array
items:
type: object
properties:
date: { type: string, format: date }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
bySource:
type: array
items:
type: object
properties:
source: { type: string, description: "(unspecified) for legacy rows with no metadata.source" }
count: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Conversation not found or not owned by the caller's team
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Conversation not found." }
code: { type: string, example: conversation_not_found }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/account-groups:
get:
x-resource-group: "accounts"
operationId: listAccountGroups
tags: [Account Groups]
summary: List groups
description: |
Returns all account groups visible to the authenticated user. Groups can
contain accounts from multiple profiles. For API keys scoped to specific
profiles, only groups whose accounts all live in allowed profiles are
returned.
responses:
'200':
description: Groups
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
accountIds:
type: array
items: { type: string }
createdBy: { type: string }
profileId:
type: string
description: |
Legacy field. Present only on groups created before
cross-profile groups were supported. New groups omit it.
examples:
example:
value:
groups:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- _id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Personal Brand"
accountIds:
- "64e1f0a9e2b5af0012ab34cf"
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "accounts"
operationId: createAccountGroup
tags: [Account Groups]
summary: Create group
description: |
Creates a new account group with a name and a list of social account IDs.
Accounts can belong to different profiles; the caller must have access to
every account's profile. Group names must be unique per user.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, accountIds]
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
profileId:
type: string
description: |
Deprecated. Accepted for backward compatibility but ignored.
Groups are no longer scoped to a single profile.
deprecated: true
example:
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
properties:
_id: { type: string }
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
message: "Account group created successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Group name already exists }
/v1/account-groups/{groupId}:
put:
x-resource-group: "accounts"
operationId: updateAccountGroup
tags: [Account Groups]
summary: Update group
description: Updates the name or account list of an existing group. You can rename the group, change its accounts, or both.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
example:
message: "Account group updated successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { description: Group name already exists }
delete:
x-resource-group: "accounts"
operationId: deleteAccountGroup
tags: [Account Groups]
summary: Delete group
description: Permanently deletes an account group. The accounts themselves are not affected.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Account group deleted successfully"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/media/presign:
post:
x-resource-group: "publishing"
operationId: getMediaPresignedUrl
tags: [Media]
summary: Get upload URL
description: Get a presigned URL to upload files directly to cloud storage (up to 5GB). Returns an uploadUrl and publicUrl. PUT your file to the uploadUrl, then use the publicUrl in your posts.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [filename, contentType]
properties:
filename:
type: string
description: Name of the file to upload
example: "my-video.mp4"
contentType:
$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`.
Usage-based (Metronome) accounts get a populated `balance`; legacy Stripe
accounts get `balance: null` plus a deprecated `legacy.limits` block and,
when payment-blocked, `status.openInvoiceUrl` / `status.declineReason`.
responses:
'200':
description: Billing snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/BillingSnapshot'
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/billing/x-pricing:
get:
x-resource-group: "billing"
operationId: getXApiPricing
tags: [Usage]
summary: Get X/Twitter API pricing table
description: |
Returns Zernio's canonical X/Twitter API pricing table. Each X action has its
own Metronome product and its own rate, and Zernio passes X API costs through
at exact rates with zero markup.
The response is identical for every authenticated user (pricing is universal),
so it is safe to cache on the client for the duration of a billing period.
To compute your own per-operation spend, pair this endpoint with
`GET /v1/usage-stats` — that endpoint returns `usage.xApiCallsByOperation`
keyed by the same `operation` field you get here.
responses:
'200':
description: X pricing table
content:
application/json:
schema:
$ref: '#/components/schemas/XApiPricing'
example:
currency: USD
markup: "0%"
source: "https://developer.x.com/#pricing"
lastVerified: "2026-03-26"
tiers:
- { tier: x_api_005, pricePerCallUsd: 0.005, operationCount: 13 }
- { tier: x_api_010, pricePerCallUsd: 0.010, operationCount: 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 — fully backward
compatible:
**Without metering params (the default):** the plan / quota / usage
snapshot — plan name, billing period, limits, usage counts, access
state. Identical to `GET /v1/usage-stats`. Existing integrations keep
working unchanged.
**With `range`, `granularity`, `from`, or `to`:** usage METERING —
billed spend (USD) by product family (`accounts`, `numbers`, `calls`,
`sms`, `dlc`, `xApi`, `credits`, `other`) over the window, at
`day` / `month` / `total` granularity, from Metronome's invoice
breakdown (the CHARGE view — always reconciles with what gets billed).
Also served at `GET /v1/usage/daily`. Usage-based accounts only —
legacy Stripe accounts get `{ "supported": false, "days": [] }`.
For per-domain consumption *volumes* use `GET /v1/usage/calls` and
`GET /v1/usage/sms`. For the billing statement (balance, credits,
caps, payment status) use `GET /v1/billing`.
parameters:
- name: reconcile
in: query
required: false
schema:
type: boolean
description: |
Snapshot mode only. For Stripe subscription users, `true` forces a
subscription reconciliation pass even when cached plan data looks
complete.
- name: range
in: query
required: false
schema:
type: string
enum: [cycle, prev-cycle, 7d, 14d, 30d, 3mo, 12mo, custom]
default: cycle
description: |
Window to report. `cycle` / `prev-cycle` resolve to the customer's
real billing-period bounds (falling back to a trailing 30 days when
no invoice exists yet); `7d`…`12mo` are trailing windows; `custom`
uses `from` / `to`.
- name: from
in: query
required: false
schema: { type: string, format: date }
description: Inclusive start (UTC date). Required when `range=custom`.
- name: to
in: query
required: false
schema: { type: string, format: date }
description: Inclusive end (UTC date). Required when `range=custom`. Max span 366 days.
- name: granularity
in: query
required: false
schema:
type: string
enum: [day, month, total]
default: day
description: |
Bucketing of the `days` series: `day` (one row per UTC day),
`month` (one row per calendar month, dated to the 1st), or `total`
(no series — read `totals`). Does not affect `totals`.
responses:
'200':
description: |
Snapshot (no metering params) or billed spend by product over the
window (with metering params).
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/UsageStats'
- $ref: '#/components/schemas/UsageMetering'
'400': { description: Invalid query parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/usage-stats:
get:
x-resource-group: "billing"
operationId: getUsageStats
tags: [Usage]
summary: Get plan and usage snapshot (plan, limits, payment status)
deprecated: true
description: |
The plan / quota / payment-status snapshot: current plan name, billing
period, plan limits, usage counts, and access state. Identical to a
bare `GET /v1/usage` call (this path is its deprecated alias). For
billed spend by product, call `GET /v1/usage` with `range` /
`granularity` params. The statement view (balance, credits, caps,
payment status) lives at `GET /v1/billing`.
The response shape depends on the account's `billingSystem`:
* Stripe users: per-period `usage.uploads` / `usage.profiles` counters.
* Metronome (usage-based) users: `usage.connectedAccounts`,
`usage.xApiCallsByOperation` (per-operation X API call counts —
resolve keys via `GET /v1/billing/x-pricing`), plus a `spend`
block with `currentPeriodCents`, `xSpendCents`, and
`xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier
aggregate is still emitted for back-compat but excludes the
$0.200 URL tier and any future tiers — new clients should
consume `xApiCallsByOperation` only.
parameters:
- name: reconcile
in: query
required: false
schema:
type: boolean
description: |
For Stripe subscription users, `true` forces a subscription
reconciliation pass even when cached plan data looks complete.
Omit the parameter, or pass `false`, to use the default
first-time-only reconciliation behavior. Invalid boolean values are
rejected.
responses:
'200':
description: Usage stats
content:
application/json:
schema:
$ref: '#/components/schemas/UsageStats'
examples:
stripe:
summary: Stripe subscription user
value:
billingSystem: stripe
planName: "Pro"
billingPeriod: "monthly"
signupDate: "2024-01-15T10:30:00Z"
billingAnchorDay: 15
limits:
uploads: 500
profiles: 10
usage:
uploads: 127
profiles: 3
lastReset: "2024-11-01T00:00:00Z"
hasAccess: true
isInvitedUser: false
autoUpgradeEnabled: false
metronome:
summary: Metronome (usage-based) user
value:
billingSystem: metronome
planName: "Usage-Based"
billingPeriod: "monthly"
limits:
uploads: -1
profiles: -1
usage:
connectedAccounts: 5
# DEPRECATED — kept for back-compat. Excludes the
# $0.200 URL tier; use xApiCallsByOperation.
xApiCalls:
x_api_005: 42
x_api_010: 1
x_api_015: 7
xApiCallsByOperation:
posts_read: 42
content_create: 7
content_create_with_url: 3
dm_event_read: 1
dm_interaction_create: 1
spend:
currentPeriodCents: 1293
creditsRemainingCents: 0
# 42 × $0.005 + 7 × $0.015 + 3 × $0.200 + 1 × $0.010 + 1 × $0.015
# = $0.21 + $0.105 + $0.60 + $0.01 + $0.015 = $0.94 -> 94¢
xSpendCents: 94
xSpendLimitCents: 1000
hasAccess: true
isInvitedUser: false
autoUpgradeEnabled: false
'400': { description: Invalid query parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/usage/calls:
get:
x-resource-group: "billing"
operationId: getCallsUsage
tags: [Usage]
summary: Calling usage and cost
description: |
Aggregated calling usage across your numbers, both channels
(WhatsApp Business Calling + regular phone/PSTN): call counts,
answered counts, minutes, and cost. Use it for cost visibility or to
rebill your own customers per number.
Costs come from each call's billing snapshot, so this endpoint always
agrees with the invoice: `billableUSD` is what Zernio bills;
`metaUSD` is the WhatsApp per-minute charge Meta bills directly to
your WABA (display only, never billed by Zernio).
Optional `groupBy` returns a breakdown by UTC day, by your number, or
by channel. Defaults to the last 30 days.
security:
- bearerAuth: []
parameters:
- { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
- { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
- { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
- { name: number, in: query, schema: { type: string }, description: "Scope to calls involving this number (typically one of YOUR numbers). E.164, leading + optional." }
- { name: groupBy, in: query, schema: { type: string, enum: [day, number, channel] } }
responses:
'200':
description: Usage totals (+ breakdown when groupBy is set).
content:
application/json:
schema:
type: object
properties:
since: { type: string, format: date-time }
until: { type: string, format: date-time }
groupBy: { type: [string, "null"], enum: [day, number, channel, null] }
totals:
type: object
properties:
calls: { type: integer }
answered: { type: integer }
minutes: { type: number }
billableUSD: { type: number, description: What Zernio bills for these calls. }
metaUSD: { type: number, description: "WhatsApp only: Meta's per-minute charge, billed by Meta directly to your WABA. Display only." }
groups:
type: array
description: Present (possibly empty) when `groupBy` is set.
items:
type: object
properties:
key: { type: string, description: "The group key: a `YYYY-MM-DD` UTC day, one of your numbers, or a channel." }
calls: { type: integer }
answered: { type: integer }
minutes: { type: number }
billableUSD: { type: number }
metaUSD: { type: number }
'400': { description: since must be before until }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/usage/sms:
get:
x-resource-group: "billing"
operationId: getSmsUsage
tags: [Usage]
summary: SMS usage (volumes)
description: |
Aggregated SMS/MMS volumes across your numbers: sent, received, and
total message counts, with an optional breakdown by UTC day or by
number. Defaults to the last 30 days.
Volumes only, deliberately: SMS cost is carrier-rated asynchronously
and billed to your invoice, so per-message cost is not available here.
Calling usage (GET /v1/usage/calls) does include billable cost.
security:
- bearerAuth: []
parameters:
- { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
- { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
- { name: number, in: query, schema: { type: string }, description: "Scope to one of YOUR SMS-enabled numbers (E.164, leading + optional)." }
- { name: groupBy, in: query, schema: { type: string, enum: [day, number] } }
responses:
'200':
description: Volume totals (+ breakdown when groupBy is set).
content:
application/json:
schema:
type: object
properties:
since: { type: string, format: date-time }
until: { type: string, format: date-time }
groupBy: { type: [string, "null"], enum: [day, number, null] }
totals:
type: object
properties:
sent: { type: integer }
received: { type: integer }
total: { type: integer }
groups:
type: array
description: Present (possibly empty) when `groupBy` is set.
items:
type: object
properties:
key: { type: string, description: "A `YYYY-MM-DD` UTC day or one of your numbers." }
sent: { type: integer }
received: { type: integer }
total: { type: integer }
'400': { description: since must be before until }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "`number` doesn't match any of your SMS-enabled numbers" }
/v1/posts:
get:
x-resource-group: "publishing"
operationId: listPosts
tags: [Posts]
summary: List posts
description: Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform.
parameters:
- $ref: '#/components/parameters/PageParam'
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 500, default: 10 }
description: 'Page size. Values above the maximum return 400 rather than being clamped.'
- name: source
in: query
schema: { type: string, enum: [zernio, external], default: zernio }
description: >-
Which collection to read. `zernio` (default) returns posts authored
through Zernio. `external` returns posts synced from the platform
(existing/historical posts that were published outside Zernio).
Combine with `accountId` and paginate via `page`/`limit` to walk the
full synced history (we keep up to the last ~12 months per account).
- name: status
in: query
schema: { type: string, enum: [draft, scheduled, published, failed] }
- name: platform
in: query
schema: { type: string, example: twitter }
- name: profileId
in: query
schema: { type: string }
description: 'Filter posts to a specific profile (24-char hex ObjectId). Omit it, or send `all` or an empty value, to list posts across every profile.'
- name: createdBy
in: query
schema: { type: string }
description: 'Filter posts to those created by a specific team user (24-char hex ObjectId).'
- name: dateFrom
in: query
schema: { type: string, format: date }
description: 'Zero-padded YYYY-MM-DD, or a full ISO 8601 datetime. An empty value means no date filter; any other malformed value returns 400.'
- name: dateTo
in: query
schema: { type: string, format: date }
description: 'Zero-padded YYYY-MM-DD, or a full ISO 8601 datetime. An empty value means no date filter; any other malformed value returns 400.'
- name: includeHidden
in: query
schema: { type: boolean, default: false }
- name: search
in: query
schema: { type: string }
description: Search posts by text content.
- name: sortBy
in: query
schema:
type: string
enum: [scheduled-desc, scheduled-asc, created-desc, created-asc, status, platform]
default: scheduled-desc
description: Sort order for results.
- name: accountId
in: query
required: false
schema: { type: string }
description: Filter posts to those published via a specific social account (24-char hex ObjectId).
responses:
'200':
description: Paginated posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostsListResponse'
examples:
scheduledPost:
summary: Scheduled post (pending publish)
value:
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
tags: ["launch"]
createdAt: "2024-10-01T12:00:00Z"
updatedAt: "2024-10-01T12:00:00Z"
pagination:
page: 1
limit: 10
total: 1
pages: 1
publishedPost:
summary: Published post with platformPostUrl
value:
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
scheduledFor: "2024-11-01T10:00:00Z"
publishedAt: "2024-11-01T10:00:05Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
tags: ["launch"]
createdAt: "2024-10-01T12:00:00Z"
updatedAt: "2024-11-01T10:00:06Z"
pagination:
page: 1
limit: 10
total: 1
pages: 1
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "publishing"
operationId: createPost
tags: [Posts]
summary: Create post
description: |
Create and optionally publish a post. Immediate posts (`publishNow: true`) include `platformPostUrl` in the response.
Content is optional when media is attached, 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). See each platform's schema for media constraints.
## Idempotency
Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post.
**1. Same-request idempotency (5-minute window).**
Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field — no new post is created. The official Zernio SDKs auto-generate a unique `x-request-id` per call. If you're using a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either:
- Set a unique `x-request-id` per logical call (recommended — UUIDv4 is fine)
- Or simply omit the header — we'll treat each request as new
**Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node.
**2. Content-hash dedup (24-hour window).**
Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. Returns `error`, `accountId`, `platform`, and `existingPostId` so you can find the original. To intentionally re-post identical content within 24h, change something (the caption, the media, the account) — the dedup is keyed on the full content fingerprint.
Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs.
parameters:
- name: x-request-id
in: header
required: false
schema: { type: string, format: uuid }
description: |
Optional client-generated request identifier for safe retry (idempotency). When two requests carry the same value, the second is treated as a retry of the first and returns the original post (HTTP 200) instead of creating a duplicate. Window is ~5 minutes from the first request. Generate a UUID per logical call. SDKs do this automatically; HTTP clients should set it themselves or omit it. See the operation description for the full idempotency contract.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: { type: string }
content:
type: string
description: 'Post caption/text. Optional when media is attached, 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
items: { $ref: '#/components/schemas/MediaItem' }
platforms:
type: array
description: Target platforms and accounts for this post. Required for non-draft posts (returns 400 if empty). Drafts can omit platforms.
items:
type: object
required: [platform, accountId]
properties:
platform: { type: string, example: twitter }
accountId: { type: string }
customContent:
type: string
description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters).
customMedia:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
scheduledFor:
type: string
format: date-time
description: Optional per-platform scheduled time override. When omitted, the top-level scheduledFor is used.
platformSpecificData:
oneOf:
- $ref: '#/components/schemas/TwitterPlatformData'
- $ref: '#/components/schemas/ThreadsPlatformData'
- $ref: '#/components/schemas/FacebookPlatformData'
- $ref: '#/components/schemas/InstagramPlatformData'
- $ref: '#/components/schemas/LinkedInPlatformData'
- $ref: '#/components/schemas/PinterestPlatformData'
- $ref: '#/components/schemas/YouTubePlatformData'
- $ref: '#/components/schemas/GoogleBusinessPlatformData'
- $ref: '#/components/schemas/TikTokPlatformData'
- $ref: '#/components/schemas/TelegramPlatformData'
- $ref: '#/components/schemas/SnapchatPlatformData'
- $ref: '#/components/schemas/RedditPlatformData'
- $ref: '#/components/schemas/BlueskyPlatformData'
- $ref: '#/components/schemas/DiscordPlatformData'
- $ref: '#/components/schemas/SlackPlatformData'
scheduledFor: { type: string, format: date-time }
publishNow: { type: boolean, default: false }
isDraft:
type: boolean
default: false
description: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically.
timezone: { type: string, default: UTC }
tags:
type: array
description: "Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed."
items: { type: string }
hashtags:
type: array
items: { type: string }
mentions:
type: array
description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
items: { type: string }
crosspostingEnabled: { type: boolean, default: true }
metadata: { type: object, additionalProperties: true }
tiktokSettings:
$ref: '#/components/schemas/TikTokPlatformData'
description: 'Root-level TikTok settings applied to the TikTok platforms sent in the same request. Merged into each platform''s platformSpecificData, with platform-specific settings taking precedence.'
facebookSettings:
$ref: '#/components/schemas/FacebookSettings'
description: 'Root-level Facebook settings applied to the Facebook platforms sent in the same request. Merged into each platform''s platformSpecificData.facebookSettings, with platform-specific settings taking precedence.'
recycling:
$ref: '#/components/schemas/RecyclingConfig'
queuedFromProfile:
type: string
description: Profile ID to schedule via queue. When provided without scheduledFor, the post is auto-assigned to the next available slot. Do not call /v1/queue/next-slot and use that time in scheduledFor, as that bypasses queue locking.
queueId:
type: string
description: |
Specific queue ID to use when scheduling via queue.
Only used when queuedFromProfile is also provided.
If omitted, uses the profile's default queue.
examples:
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 (Twitter + LinkedIn)
value:
content: "We just launched our new product!"
mediaItems:
- type: image
url: "https://example.com/launch.jpg"
platforms:
- platform: twitter
accountId: "64e1f0a9e2b5af0012ab34cd"
- platform: linkedin
accountId: "64e1f0a9e2b5af0012ab34ef"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "America/New_York"
responses:
'201':
description: Post created
content:
application/json:
schema:
$ref: '#/components/schemas/PostCreateResponse'
examples:
scheduled:
summary: Scheduled post (URLs populated after publish time)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
message: "Post scheduled successfully"
immediatePublish:
summary: Immediate post with publishNow=true (URLs included)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
message: "Post published successfully"
queueScheduled:
summary: Post scheduled via queue (using queuedFromProfile)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Scheduled via queue!"
status: "scheduled"
scheduledFor: "2024-11-01T09:00:00Z"
timezone: "America/New_York"
queuedFromProfile: "64f0a1b2c3d4e5f6a7b8c9d0"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
platforms:
- platform: "linkedin"
accountId:
_id: "64e1f0..."
platform: "linkedin"
username: "acme-corp"
displayName: "Acme Corp"
isActive: true
status: "pending"
message: "Post scheduled successfully"
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Forbidden. Distinguish by the `code` field:
- `ACCOUNT_DISCONNECTED` — a target account exists but its platform connection is no longer active (token expired or revoked, or the account was disconnected). Reconnect the account, then refresh account IDs from `GET /v1/accounts` (accounts report their connection state via `isActive`). The disconnect itself is also emitted as the `account.disconnected` webhook event.
- `PROFILE_OVER_LIMIT` — a target account belongs to a profile beyond the plan's profile limit.
- No `code` — a target `accountId` does not belong to the authenticated user (or is outside the API key's profile scope).
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: 'Account 6a0f6d2e520992756d96bb6c (facebook "My Page") is disconnected and cannot be posted to. Facebook tokens expired. Please reconnect your Facebook account. After reconnecting, refresh your account IDs from GET /v1/accounts.' }
code: { type: string, enum: [ACCOUNT_DISCONNECTED, PROFILE_OVER_LIMIT], description: Stable machine-readable cause. Absent for ownership failures. }
'409':
description: |
Duplicate content detected. Returned when the requested post matches an existing one on `(platform, accountId, content-hash)` within the last 24 hours, AND the request was NOT an `x-request-id` retry of an in-flight call. Distinct from same-`x-request-id` retries (which return HTTP 200 with the original post — see operation description for the idempotency contract).
Body fields:
- `error` — human-readable message
- `details.accountId` — the account that already has this content
- `details.platform` — the platform that already has this content
- `details.existingPostId` — Zernio `_id` of the original post
To intentionally re-post identical content within 24h, vary the content fingerprint (change the caption, swap a media item, or use a different account). To avoid 409s caused by retry loops, set a unique `x-request-id` per logical request — see `parameters.x-request-id` above.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours." }
details:
type: object
properties:
accountId: { type: string }
platform: { type: string }
existingPostId: { type: string }
'429':
description: "Rate limit exceeded. Possible causes: API rate limit, velocity limit (25 posts/hour per account), account cooldown, or daily platform limits."
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
description: Additional context about the rate limit
headers:
Retry-After:
description: Seconds until the rate limit resets (for API rate limits)
schema: { type: integer }
X-RateLimit-Limit:
description: The rate limit ceiling
schema: { type: integer }
X-RateLimit-Remaining:
description: Requests remaining in current window
schema: { type: integer }
X-RateLimit-Reset:
description: Unix timestamp (seconds since epoch) when the next slot frees up in the sliding window
schema: { type: integer }
/v1/posts/sync-external:
post:
x-resource-group: "publishing"
operationId: syncExternalPosts
tags: [Analytics]
summary: Sync an external post
description: |
Fetch an account's latest external posts (published directly on the platform, not through Zernio) on demand, so a just-published post is retrievable within seconds instead of waiting for the background sync (which refreshes each account at most every ~90 minutes).
Primary use case: verifying a submitted post. When a user publishes on the platform and immediately pastes the post URL into your app, call this with `accountId` plus `url` (or `postId`) to confirm the post exists and return its metadata.
Behavior:
- We check our stored copy first and return immediately if the post is already known (no platform call).
- Otherwise we fetch the account's latest posts live from the platform, then match and return the submitted post.
- Requests are debounced per account (~15s): if the account was just synced, the live fetch is skipped.
`accountId` is required — a post URL or id alone cannot be resolved to an account, and the account must be connected to Zernio (we use its token to read the platform). Supported for every platform with a listing API (Instagram, Facebook, TikTok, YouTube, X, Threads, Pinterest, Reddit, Bluesky, Google Business, and LinkedIn organization accounts).
LinkedIn personal profiles: LinkedIn has no listing API for personal profiles, so a `url` is REQUIRED and imports that single post. Pass any LinkedIn post URL (`linkedin.com/posts/…`, `linkedin.com/feed/update/urn:li:activity:…`) or a `urn:li:share:…` / `urn:li:ugcPost:…` URN. Works for posts published outside Zernio and before the account was connected, any age; the post must be authored by the connected member. Imported posts return full analytics (impressions, reach, reactions, comments, reshares, saves) and keep refreshing on the background analytics cycle, but carry no content/media (LinkedIn does not expose them for personal profiles).
`url` accepts any format the platform uses (e.g. `instagram.com/p/…`, `instagram.com/reel/…`, `youtu.be/…`, `youtube.com/shorts/…`, `tiktok.com/@user/video/…`, and `vm.tiktok.com` short links). Pass `postId` (the platform media/video id) as an alternative locator.
Note: post-level analytics (reach, impressions) still carry the platform's own delay (e.g. ~24h on Instagram). This endpoint confirms the post exists and returns its metadata plus basic engagement (likes, comments), not delayed insights.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: SocialAccount ID whose posts to sync. Must be connected to Zernio.
url:
type: string
description: The post URL to locate. Optional. Provide `url` or `postId` to return a specific post; omit both to just refresh and return the account's recent posts.
postId:
type: string
description: The platform post/media/video id to locate, as an alternative to `url`. Optional.
responses:
'200':
description: |
Sync result. When `url` or `postId` is provided, returns the matched post (or `found: false`). When neither is provided, returns the account's freshly-synced recent posts.
content:
application/json:
schema:
type: object
properties:
synced:
type: object
properties:
postsFound: { type: integer, description: Posts returned by the platform listing during the on-demand sync }
postsSynced: { type: integer, description: Posts inserted or updated in Zernio }
skipped: { type: boolean, description: "True when no live fetch ran: the post was already stored, or the account was synced within the debounce window" }
found:
type: boolean
description: Present only when a locator (`url`/`postId`) was provided — whether the post was found.
post:
description: The matched external post, or null when not found. Present only when a locator was provided.
oneOf:
- $ref: '#/components/schemas/ExternalPostSummary'
- type: 'null'
posts:
type: array
description: The account's recent external posts. Present only when no locator was provided.
items:
$ref: '#/components/schemas/ExternalPostSummary'
'400':
description: Invalid request (e.g. `accountId` missing or malformed)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Account not found (or not owned by the authenticated user)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/posts/{postId}:
get:
x-resource-group: "publishing"
operationId: getPost
tags: [Posts]
summary: Get post
description: |
Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Post
content:
application/json:
schema:
$ref: '#/components/schemas/PostGetResponse'
examples:
scheduledPost:
summary: Scheduled post (pending)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
publishedPost:
summary: Published post with platformPostUrl
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
failedPost:
summary: Failed post with error details
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "This post failed to publish"
status: "failed"
platforms:
- platform: "instagram"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "instagram"
username: "acmecorp"
isActive: false
status: "failed"
errorMessage: "Instagram access token has expired. Please reconnect your account."
errorCategory: "auth_expired"
errorSource: "user"
partialPost:
summary: Partial success (some platforms failed)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Launch announcement!"
status: "partial"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "threads"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "threads"
username: "acmecorp"
isActive: true
status: "failed"
errorMessage: "Post text exceeds the 500 character limit for Threads."
errorCategory: "user_content"
errorSource: "user"
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "publishing"
operationId: updatePost
tags: [Posts]
summary: Update post
description: |
Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited.
Published posts can only have their recycling config updated.
To promote a draft to scheduled, send `isDraft: false` together with `scheduledFor` (or `publishNow: true`,
or `queuedFromProfile`). If `isDraft` is omitted the post keeps its current draft status, so sending only
`scheduledFor` to a draft returns 200 but the post remains a draft.
Non-draft updates run the same per-platform validation as post creation (media requirements, platform-specific
field rules, etc.) against the resulting platforms, returning 400 on failure.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: { type: string }
content: { type: string }
mediaItems:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
platforms:
type: array
description: Target platforms and accounts for this post. Each item must include platform and accountId.
items:
type: object
required: [platform, accountId]
properties:
platform: { type: string, example: twitter }
accountId: { type: string }
customContent:
type: string
description: Platform-specific text override.
customMedia:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
scheduledFor:
type: string
format: date-time
description: Optional per-platform scheduled time override.
platformSpecificData:
type: object
additionalProperties: true
description: 'A <platform>Settings namespace (e.g. facebookSettings, tiktokSettings) omitted from the request is preserved from the stored post. Sending the key replaces the whole namespace; it is not deep-merged.'
scheduledFor: { type: string, format: date-time }
publishNow: { type: boolean, default: false }
isDraft:
type: boolean
description: When omitted, the post keeps its current draft status. Send `false` to promote a draft to scheduled (combined with `scheduledFor`, `publishNow`, or a queue).
timezone: { type: string }
visibility: { type: string, enum: [public, private, unlisted] }
tags:
type: array
items: { type: string }
hashtags:
type: array
items: { type: string }
mentions:
type: array
items: { type: string }
crosspostingEnabled: { type: boolean }
metadata: { type: object, additionalProperties: true }
queuedFromProfile:
type: string
description: Profile ID to schedule via queue.
queueId:
type: string
description: Specific queue ID to use when scheduling via queue.
tiktokSettings:
$ref: '#/components/schemas/TikTokPlatformData'
description: 'Root-level TikTok settings applied to the TikTok platforms sent in the same request. Merged into each platform''s platformSpecificData, with platform-specific settings taking precedence. Returns 400 if sent without a platforms array.'
facebookSettings:
$ref: '#/components/schemas/FacebookSettings'
description: 'Root-level Facebook settings applied to the Facebook platforms sent in the same request. Merged into each platform''s platformSpecificData.facebookSettings, with platform-specific settings taking precedence. Returns 400 if sent without a platforms array.'
recycling:
$ref: '#/components/schemas/RecyclingConfig'
additionalProperties: true
example:
content: "Updated content for our launch post!"
scheduledFor: "2024-11-02T14:00:00Z"
responses:
'200':
description: Post updated
content:
application/json:
schema:
$ref: '#/components/schemas/PostUpdateResponse'
example:
message: "Post updated successfully"
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Updated content for our launch post!"
status: "scheduled"
scheduledFor: "2024-11-02T14:00:00Z"
'207':
description: Partial publish success
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "publishing"
operationId: deletePost
tags: [Posts]
summary: Delete post
description: Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
$ref: '#/components/schemas/PostDeleteResponse'
example:
message: "Post deleted successfully"
'400':
description: Cannot delete published posts
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/posts/bulk-upload:
post:
x-resource-group: "publishing"
operationId: bulkUploadPosts
tags: [Posts]
summary: Bulk upload from CSV
description: Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts.
parameters:
- name: dryRun
in: query
schema: { type: boolean, default: false }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'200':
description: |
Bulk upload results. Returned when every row succeeded (or every row failed).
A mix of successes and failures returns `207` instead, with the same body shape.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkUploadResult'
example:
total: 1
valid: 1
invalid: 0
results:
- rowIndex: 1
ok: true
createdPostId: "69df8c12f102e11169c53cd3"
warnings: []
'207':
description: |
Partial success: some rows were created and some failed. Body is identical in
shape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`)
to see which rows failed and why.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkUploadResult'
example:
total: 1
valid: 0
invalid: 1
results:
- rowIndex: 1
ok: false
errors:
- "unknown_profile:693ae023a552465131f3bdca1"
- "no_account_for_platform:tiktok"
warnings: []
'400':
description: Invalid CSV or validation errors
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: 'Payment required: the account owner has a failed payment. Not returned on dry-run.'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'404':
description: Authenticated user not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/retry:
post:
x-resource-group: "publishing"
operationId: retryPost
tags: [Posts]
summary: Retry failed post
description: Immediately retries publishing a failed post. Returns the updated post with its new status.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Retry successful
content:
application/json:
schema:
$ref: '#/components/schemas/PostRetryResponse'
example:
message: "Post published successfully"
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Check out our new product!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "published"
platformPostId: "1234567890"
platformPostUrl: "https://twitter.com/acme/status/1234567890"
'207':
description: Partial success
'400':
description: Invalid state
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: 'Payment required: the account owner has a failed payment.'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: Post is currently publishing
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (25 posts/hour per account), or account cooldown (temporarily rate-limited due to repeated errors).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/unpublish:
post:
x-resource-group: "publishing"
operationId: unpublishPost
tags: [Posts]
summary: Unpublish post
description: |
Deletes a published post from the specified platform. The post record in Zernio is kept but its status is updated to cancelled.
Not supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to delete the post from
enum:
- threads
- facebook
- twitter
- linkedin
- youtube
- pinterest
- reddit
- bluesky
- googlebusiness
- telegram
example:
platform: "threads"
responses:
'200':
description: Post deleted from platform
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Post deleted from threads successfully"
'400':
description: "Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token."
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API deletion failed
/v1/posts/{postId}/edit:
post:
x-resource-group: "publishing"
operationId: editPost
tags: [Posts]
summary: Edit published post
description: |
Edit the text of an already-published post. Supported on X (Twitter), Discord,
Facebook, 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 (Twitter)**
- Connected X account must have an active X Premium subscription
- Must be within 1 hour of original publish time
- Maximum 5 edits per tweet (enforced by X)
- Threads cannot be edited, only single tweets
- X assigns a NEW post ID on edit, returned as `id`
**Discord**
- No time limit and no premium requirement
- The message ID is unchanged after the edit
**Facebook**
- Graph only permits editing a post that the same app created, so this works on
posts published through Zernio and is rejected for posts created in Meta
Business Suite / Composer or by another tool
- Media cannot be swapped, only the message text
- Reactions, comments, and shares are preserved. The post ID is unchanged
**Reddit**
- Self-posts only. A link post has no editable body and is rejected before the write
- Body only. Reddit exposes no API to edit a post title, ever
- The post ID is unchanged
**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 social account ID
for the connected YouTube channel.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
description: Zernio post ID, or "_" when using direct video ID mode
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to update metadata on
enum:
- youtube
videoId:
type: string
description: YouTube video ID (required for direct mode, ignored for post-based mode)
accountId:
type: string
description: Zernio social account ID (required for direct mode, ignored for post-based mode)
title:
type: string
maxLength: 100
description: New video title (max 100 characters for YouTube)
description:
type: string
description: New video description
tags:
type: array
items:
type: string
maxLength: 100
description: Array of keyword tags (max 500 characters combined for YouTube)
categoryId:
type: string
description: YouTube video category ID
privacyStatus:
type: string
enum: [public, private, unlisted]
description: Video privacy setting
thumbnailUrl:
type: string
format: uri
description: "Public URL of a custom thumbnail image (JPEG, PNG, or GIF, max 2 MB, recommended 1280x720). Works on any video you own, including existing videos not published through Zernio. The channel must be verified (phone verification) to set custom thumbnails."
madeForKids:
type: boolean
description: "COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting)."
containsSyntheticMedia:
type: boolean
description: "AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label."
playlistId:
type: string
description: "YouTube playlist ID to add the video to (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Only playlists owned by the channel are supported."
examples:
post-based:
summary: Update a video published through Zernio
value:
platform: "youtube"
title: "Updated Video Title"
description: "New SEO-optimized description"
tags: ["seo", "marketing", "tutorial"]
direct-video-id:
summary: Update a video uploaded directly to YouTube
value:
platform: "youtube"
videoId: "dQw4w9WgXcQ"
accountId: "68fb37418bbca9c10cbfef26"
title: "Updated Title with SEO Keywords"
tags: ["seo", "youtube", "optimization"]
update-thumbnail:
summary: Update thumbnail on an existing video
value:
platform: "youtube"
videoId: "dQw4w9WgXcQ"
accountId: "68fb37418bbca9c10cbfef26"
thumbnailUrl: "https://example.com/my-thumbnail.jpg"
responses:
'200':
description: Metadata updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
videoId: { type: string, description: Only present in direct video ID mode }
updatedFields:
type: array
items: { type: string }
example:
success: true
message: "YouTube video metadata updated successfully"
updatedFields: ["title", "description", "tags"]
'400':
description: "Invalid request: unsupported platform, post not published, missing fields, or validation error."
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API update failed
/v1/users:
get:
x-resource-group: "accounts"
operationId: listUsers
tags: [Users]
summary: List users
description: Returns all users in the workspace including roles and profile access. Also returns the currentUserId of the caller.
responses:
'200':
description: Users
content:
application/json:
schema:
type: object
properties:
currentUserId: { type: string }
users:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
email: { type: string }
role: { type: string }
isRoot: { type: boolean }
profileAccess:
type: array
items: { type: string }
createdAt: { type: string, format: date-time }
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 workspace: a duplicate returns a 409 whose
details.existingProfileId carries the id of the existing profile.
Send an Idempotency-Key header to make retries safe: a retried create
with the same key and body replays the original 201 (same _id)
instead of conflicting.
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
description: { type: string }
color: { type: string, example: '#ffeda0' }
example:
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileCreateResponse'
example:
message: "Profile created successfully"
profile:
_id: "64f0a1b2c3d4e5f6a7b8c9d0"
userId: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
isDefault: false
createdAt: "2024-11-01T10:00:00Z"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { $ref: '#/components/responses/PaymentRequired' }
'403': { description: Profile limit exceeded }
'409': { description: 'A profile with this name already exists (code: profile_name_conflict); details.existingProfileId carries the id of the existing profile. Also returned while a request with the same Idempotency-Key is still processing.' }
'422': { $ref: '#/components/responses/IdempotencyKeyReused' }
/v1/profiles/{profileId}:
get:
x-resource-group: "accounts"
operationId: getProfile
tags: [Profiles]
summary: Get profile
description: Returns a single profile by ID, including its name, color, and default status.
parameters:
- name: profileId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Profile
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileGetResponse'
example:
profile:
_id: "64f0a1b2c3d4e5f6a7b8c9d0"
userId: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
isDefault: false
createdAt: "2024-11-01T10:00:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "accounts"
operationId: updateProfile
tags: [Profiles]
summary: Update profile
description: Updates a profile's name, description, color, or default status.
parameters:
- name: profileId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
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 social accounts. Only includes accounts within the plan limit by default. Follower data requires analytics add-on.
Supports optional server-side pagination via page/limit params. When omitted, returns all accounts (backward-compatible).
page and limit must be supplied together; out-of-range page/limit values are rejected with 400 rather than silently clamped.
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter accounts by profile ID. Must be a valid ObjectId.
- name: platform
in: query
schema: { type: string }
description: Filter accounts by platform (e.g. "instagram", "twitter").
- name: status
in: query
schema:
type: string
enum: [connected, disconnected]
description: |
Filter accounts by connection status. `connected` returns healthy accounts; `disconnected` returns accounts that need reconnection (per the same reconnection check surfaced in the dashboard). Omit to return accounts in any status. When combined with page/limit, pagination totals reflect the filtered result set.
- name: includeOverLimit
in: query
required: false
schema:
type: boolean
default: false
description: When true, includes accounts from over-limit profiles.
- name: page
in: query
schema: { type: integer, minimum: 1 }
description: |
Page number (1-based). Must be provided together with limit to enable server-side pagination; sending only one of the two returns 400. Omit both for all accounts.
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100 }
description: |
Page size. Must be provided together with page; sending only one of the two returns 400.
responses:
'200':
description: Accounts (with optional pagination)
content:
application/json:
schema:
$ref: '#/components/schemas/AccountsListResponse'
examples:
example:
value:
accounts:
- _id: "64e1..."
platform: "twitter"
profileId:
_id: "64f0..."
name: "My Brand"
slug: "my-brand"
username: "@acme"
displayName: "Acme"
profileUrl: "https://x.com/acme"
isActive: true
hasAnalyticsAccess: false
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/follower-stats:
get:
x-resource-group: "analytics"
operationId: getFollowerStats
tags: [Accounts, Analytics]
summary: Get follower stats
description: |
Returns follower count history and growth metrics for connected social accounts.
Requires analytics add-on subscription. Follower counts are refreshed once per day.
parameters:
- name: accountIds
in: query
schema: { type: string }
description: Comma-separated list of account IDs (optional, defaults to all user's accounts)
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: fromDate
in: query
schema: { type: string, format: date }
description: Start date in YYYY-MM-DD format (defaults to 30 days ago)
- name: toDate
in: query
schema: { type: string, format: date }
description: End date in YYYY-MM-DD format (defaults to today)
- name: granularity
in: query
schema: { type: string, enum: [daily, weekly, monthly], default: daily }
description: Data aggregation level
responses:
'200':
description: Follower stats
content:
application/json:
schema:
$ref: '#/components/schemas/FollowerStatsResponse'
examples:
example:
value:
accounts:
- _id: "64e1..."
platform: "twitter"
username: "@acme"
currentFollowers: 1250
growth: 50
growthPercentage: 4.17
dataPoints: 30
stats:
"64e1...":
- date: "2024-01-01"
followers: 1200
- date: "2024-01-02"
followers: 1250
dateRange:
from: "2024-01-01T00:00:00.000Z"
to: "2024-01-31T23:59:59.999Z"
granularity: "daily"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Analytics add-on required }
message: { type: string, example: Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature. }
requiresAddon: { type: boolean, example: true }
/v1/accounts/{accountId}:
put:
x-resource-group: "accounts"
operationId: updateAccount
tags: [Accounts]
summary: Update account
description: |
Updates a connected social account's display name or username override.
For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities`
object to toggle background API operations that incur X API pass-through costs.
Both fields are opt-in (default `false`) — when off, no analytics syncs or DM
polling are performed for that account, and no API call is metered for those
operations. Publishing and deleting posts are always available regardless of
these toggles. Setting `xCapabilities` on a non-X account returns 400.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username: { type: string }
displayName: { type: string }
xCapabilities:
type: object
description: |
X/Twitter only. Per-account opt-in toggles for background API
operations that incur X API pass-through costs. Each call is
billed via Metronome at the X tier rate. Either field can be
sent independently; omitted fields are unchanged.
properties:
analytics:
type: boolean
description: |
Enable periodic analytics reads (impressions, likes, etc.)
for this X account. Each X API call is metered as
`posts_read` and billed pass-through (~$0.005/call at the
time of writing — actual rate depends on X's pricing tier).
inbox:
type: boolean
description: |
Enable DM polling and inbox sync for this X account. DM
reads are metered as `dm_event_read` (~$0.010/call) and
DM sends as `dm_interaction_create` (~$0.015/call), both
billed pass-through. DM sends fire only on user-initiated
actions; reads/polling fire only when this flag is true.
example:
displayName: "Acme Corporation Official"
xCapabilities:
analytics: true
inbox: false
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
username: { type: string }
displayName: { type: string }
xCapabilities:
type: object
description: |
Echo of the resulting `xCapabilities` state, returned only
when the request body included an `xCapabilities` object.
properties:
analytics: { type: boolean }
inbox: { type: boolean }
example:
message: "Account updated successfully"
username: "@acmecorp"
displayName: "Acme Corporation Official"
xCapabilities:
analytics: true
inbox: false
'400': { description: Invalid request (e.g. xCapabilities on a non-X account) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "accounts"
operationId: moveAccountToProfile
tags: [Accounts]
summary: Move account to another profile
description: |
Moves a connected social account to a different profile owned by the same
user. The target profile must belong to the same user as the account.
For API keys restricted to specific profiles, BOTH the source account's
current profile AND the target profile must be in the key's allowed set.
Calls with a target profile outside the key's scope return 403.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId]
properties:
profileId:
type: string
description: Target profile ID (must be a valid ObjectId and owned by the same user as the account).
example:
profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
responses:
'200':
description: Account moved
content:
application/json:
schema:
type: object
properties:
message: { type: string }
profileId: { type: string }
example:
message: "Account updated successfully"
profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
'400': { description: Missing or invalid profileId }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: API key does not have access to the source account or target profile }
'404': { description: Account or target profile not found }
delete:
x-resource-group: "accounts"
operationId: deleteAccount
tags: [Accounts]
summary: Disconnect account
description: Disconnects and removes a connected social account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Disconnected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Account disconnected successfully"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/health:
get:
x-resource-group: "accounts"
operationId: getAllAccountsHealth
tags: [Accounts]
summary: Check accounts health
description: Returns health status of all connected accounts including token validity, permissions, and issues needing attention.
parameters:
- name: profileId
in: query
description: Filter by profile ID
schema: { type: string }
- name: platform
in: query
description: Filter by platform
schema:
type: string
enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, slack, whatsapp]
- name: status
in: query
description: Filter by health status
schema:
type: string
enum: [healthy, warning, error]
responses:
'200':
description: Account health summary
content:
application/json:
schema:
type: object
properties:
summary:
type: object
properties:
total: { type: integer, description: Total number of accounts }
healthy: { type: integer, description: Number of healthy accounts }
warning: { type: integer, description: Number of accounts with warnings }
error: { type: integer, description: Number of accounts with errors }
needsReconnect: { type: integer, description: Number of accounts needing reconnection }
accounts:
type: array
items:
type: object
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
profileId: { type: string }
status: { type: string, enum: [healthy, warning, error] }
canPost: { type: boolean }
canFetchAnalytics: { type: boolean }
tokenValid: { type: boolean }
tokenExpiresAt: { type: string, format: date-time }
needsReconnect: { type: boolean }
issues: { type: array, items: { type: string } }
example:
summary:
total: 5
healthy: 3
warning: 1
error: 1
needsReconnect: 1
accounts:
- accountId: "abc123"
platform: "instagram"
username: "myaccount"
status: "healthy"
canPost: true
canFetchAnalytics: true
tokenValid: true
tokenExpiresAt: "2025-06-15T00:00:00Z"
needsReconnect: false
issues: []
- accountId: "def456"
platform: "twitter"
username: "mytwitter"
status: "error"
canPost: false
canFetchAnalytics: false
tokenValid: false
needsReconnect: true
issues: ["Token expired"]
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/whatsapp/register:
post:
x-resource-group: "accounts"
operationId: registerWhatsAppNumber
tags: [WhatsApp]
summary: Register a connected WhatsApp number on the Cloud API
description: |
Re-runs Meta's Cloud API registration for a WhatsApp account that is already connected.
Use it when the number has its own two-step verification PIN: the connect flows register
with a default PIN, Meta rejects that with error 133005, and the number then fails every
send with the misleading '(#200) You do not have the necessary permission to send messages'
while the account still shows as connected. The PIN is used for this call only and is not stored.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The WhatsApp account ID
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
pin:
type: string
pattern: '^\d{6}$'
description: 'The 6-digit two-step verification PIN set on the number. Omit it only if the number has no PIN of its own.'
example:
pin: "481902"
responses:
'200':
description: Number registered on the WhatsApp Cloud API
content:
application/json:
schema:
type: object
properties:
registered: { type: boolean }
accountId: { type: string }
phoneNumberId: { type: string }
'400':
$ref: '#/components/responses/BadRequest'
'401':
description: Invalid or expired credentials
'404':
description: WhatsApp account not found
'422':
description: 'Meta rejected the registration (e.g. PIN mismatch), or the number cannot be registered through the API.'
/v1/accounts/{accountId}/health:
get:
x-resource-group: "accounts"
operationId: getAccountHealth
tags: [Accounts]
summary: Check account health
description: |
Returns detailed health info for a specific account including token status, permissions, and recommendations.
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
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}/follow-status/{userId}:
get:
operationId: getInstagramFollowStatus
tags: [Accounts]
x-platforms: ["instagram"]
summary: Check whether an Instagram user follows the account
description: |
Resolves the follow relationship between an Instagram user and the connected
account, plus their public profile counters.
`userId` is the Instagram-scoped id (IGSID) Meta gives you on a webhook:
`sender.id` on `message.received`, `comment.author.id` on `comment.received`.
**Meta only answers for people who have MESSAGED the account.** Commenting grants
no consent, so a commenter who has never DMed you is unresolvable - that is a
platform rule, not a limitation of this endpoint. When it cannot be resolved the
response is still `200` with `isFollower: null` and an `unavailableReason`, because
"unknown" is a normal state to branch on:
* `consent_required` - the user has never messaged this account.
* `dm_access_disabled` - the account owner turned off Instagram Direct API access.
* `not_messageable` - the id is not a messaging-scoped id.
* `error` - a transient Graph API failure.
To gate a comment automation on this, use the automation's `audience` rules instead
of calling this per comment - they run the same lookup only on comments that
actually match a keyword, and can ask the commenter to confirm with one tap.
Answers are cached briefly per (account, user). Pass `refresh=true` right after
asking someone to follow, so a follow from a moment ago is visible.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: Instagram account ID }
- { name: userId, in: path, required: true, schema: { type: string }, description: 'Instagram-scoped user id (IGSID) from a webhook payload' }
- { name: refresh, in: query, required: false, schema: { type: boolean }, description: Bypass the cache and re-query Meta }
responses:
'200':
description: Follow status (fields are null when Meta would not resolve it)
content:
application/json:
schema:
type: object
required: [userId, accountId, isFollower]
properties:
userId: { type: string }
accountId: { type: string }
isFollower:
type: [boolean, "null"]
description: 'The user follows this account. Null = unknown, never "no".'
isFollowedByAccount:
type: [boolean, "null"]
description: This account follows the user.
followerCount: { type: [integer, "null"] }
isVerified: { type: [boolean, "null"] }
username: { type: [string, "null"] }
name: { type: [string, "null"] }
unavailableReason:
type: [string, "null"]
enum: [consent_required, dm_access_disabled, not_messageable, error, null]
description: 'Why the follow relationship could not be resolved. Null when it was.'
example:
userId: "1312733120229708"
accountId: "6977a34e77637c5c857c816c"
isFollower: true
isFollowedByAccount: false
followerCount: 25
isVerified: false
username: "to.bias8262"
name: "Tobi Ma"
unavailableReason: null
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/tiktok/creator-info:
get:
x-resource-group: "accounts"
operationId: getTikTokCreatorInfo
tags: [Accounts]
summary: Get TikTok creator info
description: Returns TikTok creator details, available privacy levels, posting limits, and commercial content options for a specific TikTok account. Only works with TikTok accounts.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The TikTok account ID
- name: mediaType
in: query
required: false
schema:
type: string
enum: [video, photo]
default: video
description: The media type to get creator info for (affects available interaction settings)
responses:
'200':
description: TikTok creator info and posting options
content:
application/json:
schema:
type: object
properties:
creator:
type: object
properties:
nickname: { type: string, description: Creator display name }
avatarUrl: { type: string, description: Creator avatar URL }
isVerified: { type: boolean, description: Whether the creator is verified }
canPostMore: { type: boolean, description: Whether the creator can publish more posts right now }
privacyLevels:
type: array
description: Available privacy level options for this creator
items:
type: object
properties:
value: { type: string, description: "Privacy level value to use when creating posts (e.g. PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)" }
label: { type: string, description: Human-readable label }
postingLimits:
type: object
properties:
maxVideoDurationSec: { type: integer, description: Maximum video duration in seconds }
interactionSettings:
type: object
description: Available interaction toggles (comment, duet, stitch) and their defaults
commercialContentTypes:
type: array
description: Available commercial content disclosure options
items:
type: object
properties:
value: { type: string }
label: { type: string }
requires:
type: array
items: { type: string }
example:
creator:
nickname: "myaccount"
avatarUrl: "https://example.com/avatar.jpg"
isVerified: false
canPostMore: true
privacyLevels:
- value: "PUBLIC_TO_EVERYONE"
label: "Public To Everyone"
- value: "MUTUAL_FOLLOW_FRIENDS"
label: "Mutual Follow Friends"
- value: "SELF_ONLY"
label: "Self Only"
postingLimits:
maxVideoDurationSec: 600
interactionSettings:
comment: true
duet: true
stitch: true
commercialContentTypes:
- value: "none"
label: "No Commercial Content"
- value: "brand_organic"
label: "Your Brand"
requires: ["is_brand_organic_post"]
'400':
description: Account is not a TikTok account
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "This endpoint is only available for TikTok accounts"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'429':
description: Creator has reached TikTok daily posting limit
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "TikTok creator has reached the daily posting limit. Please try again later."
/v1/auth/verify:
get:
x-resource-group: "public"
operationId: verifyCredential
tags: [API Keys]
summary: Verify credential
description: 'Checks whether the bearer credential on this request is valid, without reading any data. Accepts an API key or an OAuth access token. Intended for clients that must validate a credential before use (for example an MCP server verifying an incoming token) so they do not have to call a data endpoint to do it.'
responses:
'200':
description: Credential is valid
content:
application/json:
schema:
type: object
properties:
valid: { type: boolean }
userId: { type: string }
authType:
type: string
enum: [api_key, oauth, session]
scope:
type: [string, 'null']
description: 'Granted OAuth scopes, space-separated. Null for API keys.'
example:
valid: true
userId: "6507a1b2c3d4e5f6a7b8c9d0"
authType: "oauth"
scope: "posts:read posts:write accounts:read"
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/api-keys:
get:
x-resource-group: "admin-plane"
operationId: listApiKeys
tags: [API Keys]
summary: List keys
description: Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value.
responses:
'200':
description: API keys
content:
application/json:
schema:
type: object
properties:
apiKeys:
type: array
items: { $ref: '#/components/schemas/ApiKey' }
example:
apiKeys:
- id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Production API Key"
keyPreview: "sk_12345678...abcdef01"
expiresAt: "2025-12-31T23:59:59Z"
createdAt: "2024-01-15T10:30:00Z"
scope: "full"
profileIds: []
permission: "read-write"
- id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Analytics Read-Only"
keyPreview: "sk_87654321...12345678"
expiresAt: null
createdAt: "2024-03-20T14:45:00Z"
scope: "profiles"
profileIds:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Main Brand"
color: "#ffeda0"
permission: "read"
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
post:
x-resource-group: "admin-plane"
operationId: createApiKey
tags: [API Keys]
summary: Create key
description: Creates a new API key with an optional expiry. The full key value is only returned once in the response.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
expiresIn:
type: integer
description: Days until expiry
scope:
type: string
enum: [full, profiles]
description: "'full' grants access to all profiles (default), 'profiles' restricts to specific profiles"
default: full
profileIds:
type: array
items: { type: string }
description: Profile IDs this key can access. Required when scope is 'profiles'.
permission:
type: string
enum: [read-write, read]
description: "'read-write' allows all operations (default), 'read' restricts to GET requests only"
default: read-write
disabledResourceGroups:
type: array
items:
type: string
enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
description: >-
Resource groups to DISABLE on this key (opt-out denylist).
Omit for a legacy full-access key. A key with any group
disabled mints with the zrk_ prefix, gets 403 with
code=insufficient_permissions and required_group on
operations in disabled groups (each operation's group is
published as x-resource-group), and can never manage API
keys, invites, or member identity. With 'messages'
disabled, the key cannot read or send private messages
through any API surface and cannot create or edit a
webhook subscription broader than itself. Subscriptions
that already exist are governed by their own
`disabledResourceGroups`, not by this key's. OAuth
connector tokens resolve against the same registry, but
their groups are not settable yet.
example:
name: "No Private Messages Key"
disabledResourceGroups: ["messages", "contacts", "webhooks"]
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
message: { type: string }
apiKey: { $ref: '#/components/schemas/ApiKey' }
example:
message: "API key created successfully"
apiKey:
id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "No Private Messages Key"
keyPreview: "zrk_12345678...90abcdef"
key: "zrk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
expiresAt: null
createdAt: "2024-01-15T10:30:00Z"
scope: "full"
profileIds: []
permission: "read-write"
disabledResourceGroups: ["messages", "contacts", "webhooks"]
'400': { description: "Invalid request (missing name, invalid scope/permission, or missing profileIds when scope is 'profiles')" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/api-keys/{keyId}:
delete:
x-resource-group: "admin-plane"
operationId: deleteApiKey
tags: [API Keys]
summary: Delete key
description: Permanently revokes and deletes an API key.
parameters:
- name: keyId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "API key deleted successfully"
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/me/connected-apps:
get:
x-resource-group: "admin-plane"
operationId: listConnectedApps
tags: [Connected Apps]
summary: List connected apps
description: |
Returns the OAuth clients (AI assistants and MCP connectors) the authenticated
user has authorized and that still hold a live token.
Requires a session or a full-access API key. A profile-scoped API key, a
restricted (zrk_) API key, or an OAuth access token is rejected with 403: an
app must not be able to enumerate its sibling authorizations, and connected-app
management is admin-plane.
responses:
'200':
description: Connected apps
content:
application/json:
schema:
type: object
properties:
connectedApps:
type: array
items: { $ref: '#/components/schemas/ConnectedApp' }
example:
connectedApps:
- clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
clientName: "Claude"
redirectHost: "claude.ai"
scopes: ["posts:read", "posts:write", "accounts:read"]
authorizedAt: "2026-07-02T09:14:00Z"
lastUsedAt: "2026-07-30T18:02:11Z"
tokenCount: 2
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/me/connected-apps/{clientId}:
delete:
x-resource-group: "admin-plane"
operationId: revokeConnectedApp
tags: [Connected Apps]
summary: Revoke connected app
description: |
Ends an app's access: invalidates the client's pending authorization codes and
revokes every live token it holds for the authenticated user. Takes effect on
the app's next request.
Idempotent while the authorization is still on record: revoking an app that
was already revoked returns 200 with `revokedTokens: 0`.
Requires a session or a full-access API key. A profile-scoped API key, a
restricted (zrk_) API key, or an OAuth access token is rejected with 403.
parameters:
- name: clientId
in: path
required: true
description: 'OAuth client id, as returned by GET /v1/me/connected-apps.'
schema: { type: string, minLength: 1, maxLength: 200 }
responses:
'200':
description: Revoked
content:
application/json:
schema:
type: object
properties:
revoked: { type: boolean }
clientId: { type: string }
revokedTokens: { type: integer, description: 'Access and refresh tokens revoked by this call.' }
invalidatedCodes: { type: integer, description: 'Pending authorization codes invalidated by this call.' }
example:
revoked: true
clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
revokedTokens: 2
invalidatedCodes: 0
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
'404':
description: 'The authenticated user has never authorized this client. Error code: oauth_client_not_found.'
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/invite/tokens:
post:
x-resource-group: "admin-plane"
operationId: createInviteToken
tags: [Invites]
summary: Create invite token
description: |
Generate a secure invite link to grant team members access to your profiles.
Invites expire after 7 days and are single-use.
Returns 403 when a requested profile is not found or not owned, or when
called with a restricted (zrk_) API key: invite management is admin-plane.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scope]
properties:
scope:
type: string
enum: [all, profiles]
description: "'all' grants access to all profiles, 'profiles' restricts to specific profiles"
profileIds:
type: array
items: { type: string }
description: Required if scope is 'profiles'. Array of profile IDs to grant access to.
role:
type: string
enum: [admin, member, billing_admin, viewer]
default: member
description: Org role granted to the invitee. Defaults to 'member'. 'admin' can manage the team (invite/remove members, change roles and access) and billing, but not ownership transfer or account deletion. 'billing_admin' (displayed as Billing Manager) manages billing only. 'viewer' creates a read-only member who can view everything in their profile scope but cannot perform any content mutation (publish, edit, delete, connect accounts).
readOnly:
type: boolean
deprecated: true
description: Deprecated. Use role 'viewer' instead. When true, the invite is created with role 'viewer'. Cannot be combined with role 'billing_admin' or 'admin'.
example:
scope: "profiles"
profileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
role: "member"
responses:
'201':
description: Invite token created
content:
application/json:
schema:
type: object
properties:
token: { type: string }
scope: { type: string }
invitedProfileIds:
type: array
items: { type: string }
expiresAt: { type: string, format: date-time }
inviteUrl: { type: string, format: uri }
example:
token: "inv_abc123def456ghi789"
scope: "profiles"
invitedProfileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
expiresAt: "2024-11-08T10:30:00Z"
inviteUrl: "https://zernio.com/invite/inv_abc123def456ghi789"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/connect/{platform}:
get:
x-resource-group: "accounts"
operationId: getConnectUrl
tags: [Connect]
summary: Get OAuth connect URL
description: |
Initiate an OAuth connection flow. Returns an authUrl to redirect the user to.
Standard flow: Zernio hosts the selection UI, then redirects to your redirect_url. Headless mode (headless=true): user is redirected to your redirect_url with OAuth data for custom UI. Use the platform-specific selection endpoints to complete.
parameters:
- name: platform
in: path
required: true
schema:
type: string
enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, reddit, pinterest, bluesky, googlebusiness, telegram, snapchat, discord, slack, whatsapp]
description: Social media platform to connect
- name: profileId
in: query
required: true
schema: { type: string }
description: 'Your Zernio profile ID (get from /v1/profiles). For WhatsApp, a Zernio-provisioned number can only be connected on the profile it was provisioned to; connecting from any other profile is rejected with a 409.'
- name: redirect_url
in: query
schema: { type: string, format: uri }
description: Your custom redirect URL after connection completes. Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path. Result params are appended with the URL API, so an existing query string is preserved. Standard mode appends connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId.
- name: headless
in: query
schema: { type: boolean, default: false }
description: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.
- name: loginMethod
in: query
schema: { type: string, enum: [instagram_login, facebook_login], default: instagram_login }
description: |
Instagram only. Which of the two Instagram connection methods to use. Ignored for every other platform.
`instagram_login` (the default, and what you get if you omit this): the Instagram Login dialog. The user authorizes their Instagram professional account directly, no Facebook Page required.
`facebook_login`: the Facebook Login dialog, i.e. "Instagram API with Facebook Login". The user authorizes a Facebook Page that has a linked Instagram professional account, and every API call for that account then runs through the Page. Use this when the customer manages Instagram through a Page and expects the Facebook consent screen. Because the user has to pick which Page to connect, the callback continues at the account-selection step, `/v1/connect/instagram/select-account`.
`facebook_login` supports `headless=true` like the other selection platforms: the callback redirects to your `redirect_url` with `profileId`, `tempToken`, `platform=instagram`, `step=select_account` and `connect_token`, which you pass into the select-account endpoints to finish. The default `instagram_login` has no selection step, so it connects the account directly.
- 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).
security:
- bearerAuth: []
responses:
'200':
description: OAuth authorization URL to redirect user to
content:
application/json:
schema:
type: object
properties:
authUrl:
type: string
format: uri
description: URL to redirect your user to for OAuth authorization
state:
type: string
description: State parameter for security (handled automatically)
example:
authUrl: "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
state: "user123-profile456-1234567890-https://yourdomain.com/callback"
'400':
description: "Missing/invalid parameters (e.g., invalid profileId format)"
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { $ref: '#/components/responses/PaymentRequired' }
'403':
description: "No access to profile, or BYOK required for AppSumo Twitter"
'404':
description: Profile not found
post:
x-resource-group: "accounts"
operationId: handleOAuthCallback
tags: [Connect]
summary: Complete OAuth callback
description: |
Exchange the OAuth authorization code for tokens and connect the account to the specified profile.
Facebook, Google Business, 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 Twitter' }
'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]
summary: Connect ads for a platform
description: |
Unified ads connection endpoint. Creates a dedicated ads SocialAccount for the specified platform.
Same-token platforms (facebook, instagram, linkedin, pinterest): Creates an ads SocialAccount (metaads, linkedinads, pinterestads) with a copied OAuth token from the parent posting account. If the ads account already exists, returns alreadyConnected: true. No extra OAuth needed.
Separate-token platforms (tiktok, twitter): Starts the platform-specific marketing API OAuth flow and creates an ads SocialAccount (tiktokads, xads) with its own token. If the ads account already exists, returns alreadyConnected: true.
- tiktok: accountId is OPTIONAL. With accountId, the new tiktokads account links to that posting account (parentAccountId set) — Spark Ads + standalone ads using the posting TT_USER identity become available. Without accountId, ads-only mode kicks in: the new tiktokads account has parentAccountId=null and standalone ads use a synthetic CUSTOMIZED_USER ("Brand Identity"); Spark Ads are unavailable because TikTok requires a posting account for them. The Brand Identity is configured separately via PATCH /v1/connect/tiktok-ads (or inline on POST /v1/ads/create via the brandIdentity field).
- twitter (X Ads): accountId is REQUIRED. There's no ads-only mode — tweets need to be authored by a real X user.
Standalone platforms (googleads): Starts the Google Ads OAuth flow and creates a standalone ads SocialAccount (googleads) with no parent. If the account already exists, returns alreadyConnected: true.
Ads accounts appear as regular SocialAccount documents with ads platform values (e.g., metaads, tiktokads) in GET /v1/accounts.
parameters:
- name: platform
in: path
required: true
schema:
type: string
enum: [facebook, instagram, linkedin, tiktok, twitter, pinterest, googleads]
description: Platform to connect ads for. Only platforms with ads support are accepted.
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: accountId
in: query
schema: { type: string }
description: |
Existing SocialAccount ID. Required for `twitter` (X Ads). Optional for `tiktok` —
omit to enter ads-only mode (no TikTok posting account linked; ad creation uses
a Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`,
`instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) platforms.
- name: redirect_url
in: query
schema: { type: string, format: uri }
description: |
Custom 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. Accepts an http(s) URL, a custom app scheme
for mobile deeplinks (e.g. myapp://callback), or a relative path. 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 every platform appends error details,
starting with `error` and `platform`. 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. Supported
on `facebook`/`instagram` (Meta, `act_<digits>`), `linkedin` (bare
numeric sponsored-account id), `googleads` (bare customer id digits)
and `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers
at OAuth and `pinterest` has no ads discovery, so both ignore it.
Meta ids are additionally validated against the connected token;
unreachable IDs return 400. Setting a scope also removes already
synced ads from de-scoped ad accounts. For multiple accounts use
`adAccountIds` instead.
example: act_1330190928038136
- name: adAccountIds
in: query
style: form
explode: true
schema:
type: array
items: { type: string }
description: |
Scope ad sync to multiple platform ad accounts (same platform
support and id shapes as `adAccountId`). Repeat the param
(`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate
(`?adAccountIds=act_1,act_2`). Persisted server-side; latest call
wins, and de-scoped ad accounts have their synced ads removed.
Omitting both `adAccountId` and `adAccountIds` keeps any previously
persisted scope unchanged.
security:
- bearerAuth: []
responses:
'200':
description: Either an OAuth URL to redirect to, or confirmation that ads are already connected
content:
application/json:
schema:
oneOf:
- type: object
description: Ads already connected (no OAuth needed)
properties:
alreadyConnected: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
scopedAdAccountIds:
type: array
items: { type: string }
description: |
Echo of the persisted ad-account scope when the caller passed
`adAccountId` / `adAccountIds`. Omitted when no scope is set.
example: ["act_1330190928038136"]
- type: object
description: OAuth URL to redirect user to
properties:
authUrl: { type: string, format: uri }
state: { type: string }
examples:
alreadyConnected:
summary: Same-token platform (Meta) with existing account
value:
alreadyConnected: true
accountId: "664a1b2c3d4e5f6789012345"
platform: "instagram"
username: "@mybrand"
displayName: "My Brand"
oauthRequired:
summary: Separate-token platform (TikTok) needing ads OAuth
value:
authUrl: "https://business-api.tiktok.com/portal/auth?app_id=..."
state: "user123-profile456-account789-1234567890"
'400':
description: "Platform doesn't support ads, or missing accountId for X Ads"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: "Ads access required (Ads add-on on legacy plans, included on usage-based plans), or no access to profile"
'404':
description: "Profile or posting account not found"
/v1/connect/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. Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path. 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` uses a script-executing 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]
summary: List Facebook pages
description: Returns the list of Facebook Pages the user can manage after OAuth. Extract tempToken and userProfile from the OAuth redirect params and pass them here. Use the X-Connect-Token header if connecting via API key.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID from your connection flow
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Facebook access token from the OAuth callback redirect
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: List of Facebook Pages available for connection
content:
application/json:
schema:
type: object
properties:
pages:
type: array
items:
type: object
properties:
id: { type: string, description: Facebook Page ID }
name: { type: string, description: Page name }
username: { type: string, description: Page username/handle (may be null) }
access_token: { type: string, description: Page-specific access token }
category: { type: string, description: Page category }
tasks: { type: array, items: { type: string }, description: User permissions for this page }
example:
pages:
- id: "123456789"
name: "My Brand Page"
username: "mybrand"
access_token: "EAAxxxxx..."
category: "Brand"
tasks: ["MANAGE", "CREATE_CONTENT"]
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch pages (e.g., invalid token, insufficient permissions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
post:
x-resource-group: "accounts"
operationId: selectFacebookPage
tags: [Connect]
summary: Select Facebook page
description: Complete the headless flow by saving the user's selected Facebook page. Pass the userProfile from the OAuth redirect and use X-Connect-Token if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, pageId, tempToken, userProfile]
properties:
profileId:
type: string
description: Profile ID from your connection flow
pageId:
type: string
description: The Facebook Page ID selected by the user
tempToken:
type: string
description: Temporary Facebook access token from OAuth
userProfile:
type: object
description: Decoded user profile object from the OAuth callback
properties:
id: { type: string }
name: { type: string }
profilePicture: { type: string }
redirect_url:
type: string
format: uri
description: Optional custom redirect URL to return to after selection
example:
profileId: "507f1f77bcf86cd799439011"
pageId: "123456789"
tempToken: "EAAxxxxx..."
userProfile:
id: "987654321"
name: "John Doe"
profilePicture: "https://..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Facebook Page connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if custom redirect_url was provided
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [facebook] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
selectedPageName: { type: string }
example:
message: "Facebook page connected successfully"
redirect_url: "https://yourdomain.com/integrations/callback?connected=facebook&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=My+Brand+Page"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "facebook"
username: "mybrand"
displayName: "My Brand Page"
profilePicture: "https://..."
isActive: true
selectedPageName: "My Brand Page"
'400':
description: "Missing required fields (profileId, pageId, tempToken, or userProfile)"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: User does not have access to the specified profile
'404':
description: Selected page not found in available pages
'409':
description: |
Reconnect identity mismatch. The OAuth
was initiated as a `force=true` token-recovery re-auth
(`GET /v1/connect/{platform}/ads`), but the grant landed on a different
Facebook user or page than the connected account. The existing account
is left untouched.
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [RECONNECT_ACCOUNT_MISMATCH] }
'500':
description: Failed to save Facebook connection
/v1/connect/instagram/select-account:
get:
operationId: listInstagramPages
tags: [Connect]
summary: List Pages with a linked Instagram account
description: |
Completes the `loginMethod=facebook_login` Instagram flow, i.e. "Instagram API with Facebook Login".
After the user authorizes on Facebook, extract `tempToken` from the redirect params (headless mode adds `step=select_account`) and pass it here to list the Facebook Pages they manage. Only Pages that have a linked Instagram professional account are returned, so an empty array means the user has no eligible Page. Use the X-Connect-Token header if connecting via API key.
Not used by the default `instagram_login` flow, which creates the account without a selection step.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID from your connection flow
- name: tempToken
in: query
required: true
schema: { type: string }
description: Long-lived Facebook user access token from the OAuth callback redirect
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Facebook Pages that have a linked Instagram professional account
content:
application/json:
schema:
type: object
properties:
pages:
type: array
items:
type: object
properties:
id: { type: string, description: Facebook Page ID }
name: { type: string, description: Page name }
access_token: { type: string, description: Page-specific access token }
instagram_business_account:
type: object
description: The Instagram professional account linked to this Page
properties:
id: { type: string, description: Instagram Business Account ID }
username: { type: string }
profile_picture_url: { type: string }
example:
pages:
- id: "811889972008357"
name: "My Brand Page"
access_token: "EAAxxxxx..."
instagram_business_account:
id: "17841400649984407"
username: "mybrand"
profile_picture_url: "https://..."
'400': { description: 'Missing required parameters (profileId or tempToken)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: User does not have access to the specified profile }
post:
operationId: selectInstagramAccount
tags: [Connect]
summary: Select the Page whose Instagram account to connect
description: |
Saves the selected Page as an Instagram account connected via Facebook Login. The Page access token becomes the account's access token, so every Instagram call for it runs against the Facebook Graph host.
One Instagram account per profile: if the profile already has an Instagram account, this replaces it, and picking a different Instagram identity purges the previous account's conversations, external posts and stats.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, pageId, tempToken]
properties:
profileId:
type: string
description: Profile ID from your connection flow
pageId:
type: string
description: 'The Facebook Page ID selected by the user, from GET /v1/connect/instagram/select-account'
tempToken:
type: string
description: Long-lived Facebook user access token from the OAuth callback redirect
redirect_url:
type: string
format: uri
description: Optional custom redirect URL to return to after selection
example:
profileId: "507f1f77bcf86cd799439011"
pageId: "811889972008357"
tempToken: "EAAxxxxx..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Instagram account connected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if a custom redirect_url was provided
account:
type: object
properties:
accountId: { type: string, description: ID of the created SocialAccount }
platform: { type: string, enum: [instagram] }
username: { type: string }
displayName: { type: string, description: Name of the Facebook Page backing this account }
profilePicture: { type: string }
isActive: { type: boolean }
loginMethod: { type: string, enum: [facebook_login] }
example:
message: "Instagram (via Facebook) connected"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "instagram"
username: "mybrand"
displayName: "My Brand Page"
profilePicture: "https://..."
isActive: true
loginMethod: "facebook_login"
'400':
description: 'Missing required fields, or the selected Page has no linked Instagram professional account'
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { $ref: '#/components/responses/PaymentRequired' }
'403': { description: User does not have access to the specified profile }
'404': { description: Selected page not found among the pages this token can manage }
/v1/connect/googlebusiness/locations:
get:
x-resource-group: "accounts"
operationId: listGoogleBusinessLocations
tags: [Connect]
summary: List GBP locations
description: >
For headless flows. Returns the list of GBP locations the user can manage.
Use pendingDataToken (from the OAuth callback redirect) to list locations
without consuming the token, so it remains available for select-location.
Use X-Connect-Token header if connecting via API key.
parameters:
- name: profileId
in: query
required: false
schema: { type: string }
description: Profile ID from your connection flow. Required for auth validation when provided.
- name: pendingDataToken
in: query
required: false
schema: { type: string }
description: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required.
- name: tempToken
in: query
required: false
schema: { type: string }
description: Legacy. Direct Google access token. Use pendingDataToken instead when available.
- name: search
in: query
required: false
schema: { type: string }
description: >
Free-text search on the business name, applied server-side by Google.
Use this for accounts that own many locations (the response is bounded,
see hasMore) so the user can find a specific location without loading
the full list.
- name: filter
in: query
required: false
schema: { type: string }
description: >
Raw Google Business Information API filter expression (advanced;
takes precedence over search). Supports fields such as title, storeCode,
storefront_address.postal_code, labels and categories, e.g.
storeCode="LH279411". See Google's "Work with location data" guide.
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: List of Google Business locations available for connection
content:
application/json:
schema:
type: object
properties:
locations:
type: array
items:
type: object
properties:
id: { type: string, description: Location ID }
name: { type: string, description: Business name }
accountId: { type: string, description: Google Business Account ID }
accountName: { type: string, description: Account name }
address: { type: string, description: Business address }
category: { type: string, description: Business category }
storeCode: { type: string, description: Store code set on the location in Google Business Profile (if any) }
hasMore:
type: boolean
description: >
True when more locations exist than were returned (the list is
bounded). Prompt the user to narrow the result set with search.
example:
locations:
- id: "9281089117903930794"
name: "My Coffee Shop"
accountId: "accounts/113303573364907650416"
accountName: "My Business Account"
address: "123 Main St, City, Country"
category: "Coffee shop"
storeCode: "CS-001"
hasMore: false
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch locations (e.g., invalid token, insufficient permissions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
/v1/connect/googlebusiness/select-location:
post:
x-resource-group: "accounts"
operationId: selectGoogleBusinessLocation
tags: [Connect]
summary: Select GBP location
description: >
Complete the headless GBP flow by saving the user's selected location.
The pendingDataToken is returned in your redirect URL after OAuth completes
(step=select_location). Tokens and profile data are stored server-side,
so only the pendingDataToken is needed here. Use X-Connect-Token header
if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, locationId, pendingDataToken]
properties:
profileId:
type: string
description: Profile ID from your connection flow
locationId:
type: string
description: The Google Business location ID selected by the user
accountId:
type: string
description: >
Optional but recommended. The Google Business Account resource name
("accounts/123") that owns the selected location (returned per-location
by GET /v1/connect/googlebusiness/locations). When provided, the location
is resolved directly instead of by enumerating the account, which is
required for accounts that own many locations. Omit only for small accounts.
pendingDataToken:
type: string
description: Token from the OAuth callback redirect (pendingDataToken query param). Tokens and profile data are retrieved server-side from this token.
redirect_url:
type: string
format: uri
description: Optional custom redirect URL to return to after selection
example:
profileId: "507f1f77bcf86cd799439011"
locationId: "9281089117903930794"
accountId: "accounts/113303573364907650416"
pendingDataToken: "a1b2c3d4e5f6..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Google Business location connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if custom redirect_url was provided
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [googlebusiness] }
username: { type: string }
displayName: { type: string }
isActive: { type: boolean }
selectedLocationName: { type: string, description: 'Human-readable location display name, NOT a resource name. Do not use it to build API paths.' }
selectedLocationId: { type: string, description: 'Bare GBP location id. Combine with the GBP account id as accounts/{gbpAccountId}/locations/{selectedLocationId} to form the location resource names that gmb-reviews/batch expects in locationNames.' }
example:
message: "Google Business location connected successfully"
redirect_url: "https://yourdomain.com/integrations/callback?connected=googlebusiness&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=My+Coffee+Shop"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "googlebusiness"
username: "My Coffee Shop"
displayName: "My Coffee Shop"
isActive: true
selectedLocationName: "My Coffee Shop"
selectedLocationId: "9281089117903930794"
'400':
description: 'Missing required fields (profileId, locationId, or tempToken), or the provided accountId is not one of the accounts this connection manages'
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: User does not have access to the specified profile
'404':
description: Selected location not found in available locations
'500':
description: Failed to save Google Business connection
/v1/accounts/{accountId}/gmb-reviews:
get:
x-resource-group: "engagement"
operationId: getGoogleBusinessReviews
tags: [GMB Reviews]
summary: Get reviews
description: Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 50 }
description: Number of reviews to fetch per page (max 50)
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from previous response
security:
- bearerAuth: []
responses:
'200':
description: Reviews fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
reviews:
type: array
items: { $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 account or missing location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "This endpoint is only available for Google Business Profile accounts"
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "You do not have permission to access reviews for this location."
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to fetch reviews
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-verifications:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessVerifications
tags: [GMB Verifications]
summary: Get verification state
description: >-
Returns the location's Voice of Merchant state plus its verification
history. `voiceOfMerchantState.hasVoiceOfMerchant` tells you whether the
listing is verified and published; when it is false, `verify` reports
whether a verification is already pending. Each entry in `verifications`
has a `state` of PENDING, COMPLETED, or FAILED.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
responses:
'200':
description: Verification state fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
voiceOfMerchantState:
type: object
description: Raw Voice of Merchant state from Google.
properties:
hasVoiceOfMerchant: { type: boolean, description: "True when the listing is verified and published (eligible to surface reviews, edits, etc.)." }
hasBusinessAuthority: { type: boolean, description: True when the authenticated user has owner/manager authority over the listing. }
verify:
type: object
description: Present when verification is the path to Voice of Merchant.
properties:
hasPendingVerification: { type: boolean, description: True when a verification is already in progress. }
verifications:
type: array
description: Verification history, newest first. Empty when none exist.
items:
type: object
properties:
name: { type: string, description: 'Resource name, e.g. "locations/123/verifications/0T1776879124712". The last segment is the verificationId.' }
method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER], description: Method used (omitted on some entries). }
state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
createTime: { type: string, format: date-time }
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "16699729527667179850"
voiceOfMerchantState:
hasVoiceOfMerchant: false
hasBusinessAuthority: true
verify:
hasPendingVerification: false
verifications:
- name: "locations/16699729527667179850/verifications/4T1775504407480"
method: "SMS"
state: "FAILED"
createTime: "2026-04-06T19:40:07.480Z"
'400':
description: Not a Google Business account or missing location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
post:
x-resource-group: "accounts"
operationId: startGoogleBusinessVerification
tags: [GMB Verifications]
summary: Start a verification
description: >-
Starts a verification for the location. This is a mutating action:
depending on `method`, Google mails a postcard, places a call, or sends
an SMS/email to the business. Submit the resulting code with POST
/gmb-verifications/{verificationId}/complete. Use POST
/gmb-verifications/options first to discover which methods are eligible.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [method]
properties:
method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER], description: The verification method. Selects which method-specific field below is required. }
languageCode: { type: string, example: "en-US" }
phoneNumber: { type: string, description: For PHONE_CALL / SMS. }
emailAddress: { type: string, description: For EMAIL. }
mailerContact: { type: object, description: For ADDRESS (postcard) verification. }
context: { type: object, description: ServiceBusinessContext (e.g. service address). Required for service-area businesses. }
example:
method: "SMS"
languageCode: "en-US"
phoneNumber: "+14155550123"
responses:
'200':
description: Verification started
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
verification:
type: object
properties:
name: { type: string }
method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
createTime: { type: string, format: date-time }
'400':
description: Invalid request (e.g. wrong field for the chosen method, or Google rejected it)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/gmb-verifications/options:
post:
x-resource-group: "accounts"
operationId: fetchGoogleBusinessVerificationOptions
tags: [GMB Verifications]
summary: Fetch verification options
description: >-
Reports the verification methods Google currently offers for the
location. Non-mutating (nothing is sent to the business). `languageCode`
is required; service-area ("CUSTOMER_LOCATION_ONLY") businesses also
require `context.address`, otherwise Google returns 400.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string, minLength: 1 }
description: Override which location to query. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [languageCode]
properties:
languageCode: { type: string, example: "en-US" }
context: { type: object, description: ServiceBusinessContext. Required for service-area businesses (must include the service address). }
example:
languageCode: "en-US"
responses:
'200':
description: Verification options fetched
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
options:
type: array
items:
type: object
properties:
verificationMethod: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
phoneNumber: { type: string, description: Present for PHONE_CALL / SMS. }
'400':
description: Invalid request (e.g. missing service business context, or missing languageCode)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/gmb-verifications/{verificationId}/complete:
post:
x-resource-group: "accounts"
operationId: completeGoogleBusinessVerification
tags: [GMB Verifications]
summary: Complete a verification
description: >-
Completes a PENDING verification by submitting the PIN/code Google sent
the business (postcard code, SMS PIN, etc.). On success the verification
moves to COMPLETED.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: verificationId
in: path
required: true
schema: { type: string }
description: The last segment of a verification `name` from GET /gmb-verifications.
- name: locationId
in: query
schema: { type: string, minLength: 1 }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pin]
properties:
pin: { type: string, description: The code Google sent to the business. }
example:
pin: "123456"
responses:
'200':
description: Verification completed
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
verification:
type: object
properties:
name: { type: string }
method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
createTime: { type: string, format: date-time }
'400':
description: Invalid request (e.g. wrong PIN or verification not pending)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/gmb-food-menus:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessFoodMenus
tags: [GMB Food Menus]
summary: Get food menus
description: Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
responses:
'200':
description: Food menus fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
name: { type: string, description: Resource name of the food menus }
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "9281089117903930794"
name: "accounts/123456789/locations/9281089117903930794/foodMenus"
menus:
- labels:
- displayName: "Lunch Menu"
description: "Available 11am-3pm"
languageCode: "en"
sections:
- labels:
- displayName: "Appetizers"
items:
- labels:
- displayName: "Caesar Salad"
description: "Romaine, parmesan, croutons"
attributes:
price:
currencyCode: "USD"
units: "12"
dietaryRestriction: ["VEGETARIAN"]
'400':
description: Invalid request - not a Google Business account or missing location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "This endpoint is only available for Google Business Profile accounts"
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "You do not have permission to access food menus for this location."
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to fetch food menus
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessFoodMenus
tags: [GMB Food Menus]
summary: Update food menus
description: Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [menus]
properties:
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
description: Array of food menus to set
updateMask:
type: string
description: Field mask for partial updates (e.g. "menus")
example:
menus:
- labels:
- displayName: "Dinner Menu"
languageCode: "en"
sections:
- labels:
- displayName: "Mains"
items:
- labels:
- displayName: "Grilled Salmon"
description: "With seasonal vegetables"
attributes:
price:
currencyCode: "USD"
units: "24"
allergen: ["FISH"]
updateMask: "menus"
responses:
'200':
description: Food menus updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
name: { type: string }
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Request body must include a \"menus\" array"
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to update food menus
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-location-details:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessLocationDetails
tags: [GMB Location Details]
summary: Get location details
description: Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: readMask
in: query
required: false
schema: { type: string }
description: |
Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours.
`title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here.
Note: `location` is a derived response field, not a Google readMask value, passing it returns 400.
security:
- bearerAuth: []
responses:
'200':
description: Location details fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
location:
type: [object, "null"]
description: |
Compact public-facing summary derived from Google's `metadata`. Useful
for surfacing the "leave a review" URL (e.g. behind a QR code) without
parsing the raw block. Always populated regardless of readMask.
For unverified or new locations Google omits placeId/reviewUrl/mapsUri,
so those return as null and `isVerified` is false.
properties:
name: { type: [string, "null"], description: Business name as set in GBP }
placeId: { type: [string, "null"], description: Google Maps Place ID for this location }
reviewUrl: { type: [string, "null"], description: Public "write a review" URL Google generates for this place }
mapsUri: { type: [string, "null"], description: Public Google Maps URL for this location }
isVerified: { type: boolean, description: True when the location has Voice of Merchant (verified + live on Google) }
title: { type: string, description: Business name }
regularHours:
type: object
properties:
periods:
type: array
items:
type: object
properties:
openDay: { type: string, enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] }
openTime: { type: string, description: "Opening time in HH:MM format" }
closeDay: { type: string }
closeTime: { type: string }
specialHours:
type: object
properties:
specialHourPeriods:
type: array
items:
type: object
properties:
startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
openTime: { type: string }
closeTime: { type: string }
closed: { type: boolean }
profile:
type: object
properties:
description: { type: string, description: Business description }
websiteUri: { type: string }
phoneNumbers:
type: object
properties:
primaryPhone: { type: string }
additionalPhones: { type: array, items: { type: string } }
categories:
type: object
description: "Business categories (returned when readMask includes 'categories')"
properties:
primaryCategory:
type: object
properties:
name: { type: string, description: "Category resource name" }
displayName: { type: string, description: "Human-readable category name" }
additionalCategories:
type: array
items:
type: object
properties:
name: { type: string }
displayName: { type: string }
serviceItems:
type: array
description: "Services offered (returned when readMask includes 'serviceItems')"
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
languageCode: { type: string }
price:
type: object
properties:
currencyCode: { type: string }
units: { type: string }
nanos: { type: integer }
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "9281089117903930794"
location:
name: "Joe's Pizza"
placeId: "ChIJExampleJoesPizzaPlaceId"
reviewUrl: "https://search.google.com/local/writereview?placeid=ChIJExampleJoesPizzaPlaceId"
mapsUri: "https://maps.google.com/maps?cid=1234567890123456789"
isVerified: true
title: "Joe's Pizza"
regularHours:
periods:
- openDay: "MONDAY"
openTime: "11:00"
closeDay: "MONDAY"
closeTime: "22:00"
- openDay: "TUESDAY"
openTime: "11:00"
closeDay: "TUESDAY"
closeTime: "22:00"
specialHours:
specialHourPeriods:
- startDate: { year: 2026, month: 12, day: 25 }
closed: true
profile:
description: "Authentic New York style pizza since 1985"
websiteUri: "https://joespizza.com"
categories:
primaryCategory:
name: "categories/gcid:pizza_restaurant"
displayName: "Pizza restaurant"
additionalCategories:
- name: "categories/gcid:italian_restaurant"
displayName: "Italian restaurant"
'400':
description: |
Invalid request. Most commonly raised when the readMask query
includes a value that is not a valid Google Business Information
field (e.g. `location`, which is a response-only derived field).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Request contains an invalid argument."
code: "gbp_bad_request"
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessLocationDetails
tags: [GMB Location Details]
summary: Update location details
description: |
Updates GBP location details. The updateMask field is required and specifies which fields to update.
This endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported.
Common fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [updateMask]
properties:
updateMask:
type: string
description: "Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported."
regularHours:
type: object
properties:
periods:
type: array
items:
type: object
properties:
openDay: { type: string }
openTime: { type: string }
closeDay: { type: string }
closeTime: { type: string }
specialHours:
type: object
properties:
specialHourPeriods:
type: array
items:
type: object
properties:
startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
openTime: { type: string }
closeTime: { type: string }
closed: { type: boolean }
profile:
type: object
properties:
description: { type: string }
websiteUri: { type: string }
phoneNumbers:
type: object
properties:
primaryPhone: { type: string }
additionalPhones: { type: array, items: { type: string } }
categories:
type: object
description: "Primary and additional business categories. Use updateMask='categories' to update."
properties:
primaryCategory:
type: object
properties:
name:
type: string
description: "Category resource name (e.g. 'categories/gcid:laundromat'). Use Google's Categories API to look up valid IDs."
additionalCategories:
type: array
items:
type: object
properties:
name:
type: string
description: "Category resource name (e.g. 'categories/gcid:dry_cleaner')"
serviceItems:
type: array
description: "Services offered by the business. Use updateMask='serviceItems' to update."
items:
type: object
properties:
structuredServiceItem:
type: object
description: "A predefined service from Google's service type catalog"
properties:
serviceTypeId:
type: string
description: "Service type ID from Google's catalog (e.g. 'job_type_id:plumbing_drain_repair')"
description:
type: string
description: "Optional description of the service"
freeFormServiceItem:
type: object
description: "A custom service not in Google's catalog"
properties:
category:
type: string
description: "Category resource name this service belongs to (e.g. 'categories/gcid:laundromat')"
label:
type: object
properties:
displayName:
type: string
description: "Service name as displayed to users"
languageCode:
type: string
description: "Language code (e.g. 'en')"
price:
type: object
description: "Optional price for the service"
properties:
currencyCode: { type: string, description: "ISO 4217 currency code (e.g. 'USD')" }
units: { type: string, description: "Whole units of the amount" }
nanos: { type: integer, description: "Nano units (10^-9) of the amount" }
examples:
updateHours:
summary: Update business hours
value:
updateMask: "regularHours,specialHours"
regularHours:
periods:
- openDay: "MONDAY"
openTime: "09:00"
closeDay: "MONDAY"
closeTime: "17:00"
- openDay: "SATURDAY"
openTime: "10:00"
closeDay: "SATURDAY"
closeTime: "14:00"
specialHours:
specialHourPeriods:
- startDate: { year: 2026, month: 12, day: 25 }
closed: true
- startDate: { year: 2026, month: 12, day: 31 }
openTime: "09:00"
closeTime: "15:00"
updateCategories:
summary: Update business categories
value:
updateMask: "categories"
categories:
primaryCategory:
name: "categories/gcid:laundromat"
additionalCategories:
- name: "categories/gcid:dry_cleaner"
- name: "categories/gcid:laundry_service"
updateServices:
summary: Update service items
value:
updateMask: "serviceItems"
serviceItems:
- structuredServiceItem:
serviceTypeId: "job_type_id:plumbing_drain_repair"
description: "Full drain cleaning and repair service"
- freeFormServiceItem:
category: "categories/gcid:laundromat"
label:
displayName: "Wash & Fold Service"
languageCode: "en"
price:
currencyCode: "USD"
units: "25"
responses:
'200':
description: Location updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
'400':
description: Invalid request or missing updateMask
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/gmb-media:
get:
x-resource-group: "accounts"
operationId: listGoogleBusinessMedia
tags: [GMB Media]
summary: List media
description: |
Lists media items (photos) for a Google Business Profile location.
Returns photo URLs, descriptions, categories, and metadata.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
description: Number of items to return (max 100)
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from previous response
security:
- bearerAuth: []
responses:
'200':
description: Media items fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
mediaItems:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
mediaFormat: { type: string, enum: [PHOTO, VIDEO] }
sourceUrl: { type: string }
googleUrl: { type: string, description: Google-hosted URL }
thumbnailUrl: { type: string }
description: { type: string }
createTime: { type: string, format: date-time }
locationAssociation:
type: object
properties:
category: { type: string }
nextPageToken: { type: string }
totalMediaItemsCount: { type: integer }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
x-resource-group: "accounts"
operationId: createGoogleBusinessMedia
tags: [GMB Media]
summary: Upload photo
description: |
Creates a media item (photo) for a location from a publicly accessible URL.
Categories determine where the photo appears: CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, ADDITIONAL.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sourceUrl]
properties:
sourceUrl: { type: string, format: uri, description: Publicly accessible image URL }
mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO }
description: { type: string, minLength: 1, description: Photo description }
category:
type: string
enum: [CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, ADDITIONAL]
description: Where the photo appears on the listing
example:
sourceUrl: "https://example.com/photos/restaurant-interior.jpg"
description: "Dining area with outdoor seating"
category: "INTERIOR"
responses:
'200':
description: Media created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
mediaFormat: { type: string }
googleUrl: { type: string }
'400':
description: Invalid request or unsupported media format
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
x-resource-group: "accounts"
operationId: deleteGoogleBusinessMedia
tags: [GMB Media]
summary: Delete photo
description: Deletes a photo or media item from a GBP location.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: mediaId
in: query
required: true
schema: { type: string }
description: The media item ID to delete
security:
- bearerAuth: []
responses:
'200':
description: Media deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: boolean }
mediaId: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-attribute-metadata:
get:
x-resource-group: "accounts"
operationId: getGmbAttributeMetadata
tags: [GMB Attributes]
summary: Get attribute metadata
description: |
Returns metadata about which Google Business Profile attributes are available for
a location or business category. Use this endpoint to discover valid attribute names,
value types, and allowed enum values before reading or writing via gmb-attributes.
Two mutually exclusive query modes:
**Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`).
Google returns attributes valid for that specific location.
**Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`.
Google returns attributes valid for that category across the given region.
`languageCode` is optional in category mode.
Both modes support `pageSize` and `pageToken` for pagination.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: >
GBP location ID (e.g. "6257659026299438786"). If omitted, uses the account's
stored selectedLocationId. Mutually exclusive with categoryName.
- name: categoryName
in: query
schema: { type: string }
description: >
Category resource name, must start with "categories/" (e.g.
"categories/gcid:plumber"). Required together with regionCode. Mutually
exclusive with locationId.
- name: regionCode
in: query
schema: { type: string }
description: >
BCP-47 region code (e.g. "US", "ES"). Required when categoryName is provided.
- name: languageCode
in: query
schema: { type: string }
description: >
BCP-47 language code for display names (e.g. "en", "es"). Optional when
categoryName is provided. Omitted from the Google call when not supplied.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200 }
description: Maximum number of attribute metadata items to return. Google defaults to 200.
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from a previous response's nextPageToken field.
security:
- bearerAuth: []
responses:
'200':
description: Attribute metadata fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId:
type: string
description: Only present in location mode.
attributeMetadata:
type: array
items:
type: object
properties:
parent:
type: string
description: Resource name of the attribute (e.g. "attributes/has_delivery").
valueType:
type: string
description: Value type (e.g. BOOL, ENUM, URL, REPEATED_ENUM).
displayName:
type: string
description: Localized human-readable attribute name.
groupDisplayName:
type: string
description: Display name of the attribute group.
repeatable:
type: boolean
description: True if multiple values can be set simultaneously.
deprecated:
type: boolean
description: True if this attribute should no longer be used.
valueMetadata:
type: array
description: Possible enum values (for ENUM / REPEATED_ENUM types).
items:
type: object
properties:
value: { type: string }
displayName: { type: string }
nextPageToken:
type: string
description: Present when additional pages of results are available.
example:
success: true
accountId: "64f1a2b3c4d5e6f7a8b9c0d1"
locationId: "6257659026299438786"
attributeMetadata:
- parent: "attributes/has_delivery"
valueType: "BOOL"
displayName: "Delivery"
groupDisplayName: "Service options"
- parent: "attributes/pay_credit_card_types_accepted"
valueType: "REPEATED_ENUM"
displayName: "Credit cards accepted"
valueMetadata:
- value: "visa"
displayName: "Visa"
- value: "mastercard"
displayName: "Mastercard"
'400':
description: Invalid request (mixed modes, missing required params, wrong platform, or Google returned 4xx)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Access token is invalid or revoked. Reconnect the Google Business Profile account.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404':
description: Account not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-attributes:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessAttributes
tags: [GMB Attributes]
summary: Get attributes
description: Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
responses:
'200':
description: Attributes fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
attributes:
type: array
items:
type: object
properties:
name: { type: string, description: "Attribute identifier (e.g. has_delivery)" }
valueType: { type: string, description: "Value type (BOOL, ENUM, URL, REPEATED_ENUM)" }
values: { type: array, items: {} }
repeatedEnumValue:
type: object
properties:
setValues: { type: array, items: { type: string } }
unsetValues: { type: array, items: { type: string } }
example:
success: true
attributes:
- name: "has_delivery"
valueType: "BOOL"
values: [true]
- name: "has_takeout"
valueType: "BOOL"
values: [true]
- name: "has_outdoor_seating"
valueType: "BOOL"
values: [true]
- name: "pay_credit_card_types_accepted"
valueType: "REPEATED_ENUM"
repeatedEnumValue:
setValues: ["visa", "mastercard", "amex"]
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessAttributes
tags: [GMB Attributes]
summary: Update attributes
description: |
Updates location attributes (amenities, services, etc.).
The attributeMask specifies which attributes to update (comma-separated).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [attributes, attributeMask]
properties:
attributes:
type: array
items:
type: object
required: [name]
properties:
name: { type: string, minLength: 1 }
valueType:
type: string
enum: [ATTRIBUTE_VALUE_TYPE_UNSPECIFIED, BOOL, ENUM, URL, REPEATED_ENUM]
values: { type: array, items: {} }
repeatedEnumValue:
type: object
properties:
setValues: { type: array, items: { type: string } }
unsetValues: { type: array, items: { type: string } }
uriValues:
type: array
items:
type: object
required: [uri]
properties:
uri: { type: string, format: uri }
attributeMask:
type: string
minLength: 1
description: "Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout')"
example:
attributes:
- name: "has_delivery"
values: [true]
- name: "has_takeout"
values: [true]
- name: "has_outdoor_seating"
values: [false]
attributeMask: "has_delivery,has_takeout,has_outdoor_seating"
responses:
'200':
description: Attributes updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
attributes: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-place-actions:
get:
x-resource-group: "accounts"
operationId: listGoogleBusinessPlaceActions
tags: [GMB Place Actions]
summary: List action links
description: |
Lists place action links for a Google Business Profile location.
Place actions are the booking, ordering, and reservation buttons that appear on your listing.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
- name: pageToken
in: query
schema: { type: string }
security:
- bearerAuth: []
responses:
'200':
description: Place actions fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
placeActionLinks:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
uri: { type: string, description: Action URL }
placeActionType: { type: string }
createTime: { type: string, format: date-time }
updateTime: { type: string, format: date-time }
nextPageToken: { type: string }
example:
success: true
placeActionLinks:
- name: "locations/123/placeActionLinks/456"
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
- name: "locations/123/placeActionLinks/789"
uri: "https://www.opentable.com/joespizza"
placeActionType: "DINING_RESERVATION"
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
x-resource-group: "accounts"
operationId: createGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Create action link
description: |
Creates a place action link for a location.
Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [uri, placeActionType]
properties:
uri: { type: string, format: uri, description: The action URL }
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: Type of action
example:
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
responses:
'200':
description: Place action created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string, description: Resource name of the created link }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
x-resource-group: "accounts"
operationId: deleteGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Delete action link
description: Deletes a place action link (e.g. booking or ordering URL) from a GBP location.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: name
in: query
required: true
schema: { type: string }
description: "The resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
security:
- bearerAuth: []
responses:
'200':
description: Place action deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: boolean }
name: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
patch:
x-resource-group: "accounts"
operationId: updateGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Update action link
description: |
Updates a place action link (change URL or action type).
Only the fields included in the request body will be updated.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
description: "Resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
uri:
type: string
format: uri
description: "New action URL. At least one of uri or placeActionType is required (enforced server-side; not modeled as anyOf because required-only anyOf branches break SDK generators)."
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: New action type
example:
name: "locations/123/placeActionLinks/456"
uri: "https://order.doordash.com/joespizza"
responses:
'200':
description: Place action updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-services:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessServices
tags: [GMB Services]
summary: Get services
description: |
Gets the services offered by a Google Business Profile location.
Returns an array of service items (structured or free-form with optional price).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location.
security:
- bearerAuth: []
responses:
'200':
description: Services fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
services:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
description: { type: string }
price:
type: object
properties:
currencyCode: { type: string, example: "USD" }
units: { type: string, example: "50" }
nanos: { type: integer }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessServices
tags: [GMB Services]
summary: Replace services
description: |
Replaces the entire service list for a location.
Google's API requires full replacement; individual item updates are not supported.
Each service can be structured (using a predefined serviceTypeId) or free-form (custom label).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [serviceItems]
properties:
serviceItems:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
required: [serviceTypeId]
description: "Exactly one of structuredServiceItem or freeFormServiceItem is required per item (enforced server-side; not modeled as oneOf because required-only oneOf branches break SDK generators)."
properties:
serviceTypeId: { type: string, minLength: 1 }
description: { type: string }
freeFormServiceItem:
type: object
required: [category, label]
properties:
category: { type: string, minLength: 1 }
label:
type: object
required: [displayName]
properties:
displayName: { type: string, minLength: 1 }
description: { type: string }
languageCode: { type: string }
price:
type: object
properties:
currencyCode: { type: string, minLength: 1 }
units: { type: string, minLength: 1 }
nanos: { type: integer }
example:
serviceItems:
- freeFormServiceItem:
category: "categories/gcid:plumber"
label: { displayName: "Pipe Repair", description: "Emergency and scheduled pipe repair" }
price: { currencyCode: "USD", units: "150" }
responses:
'200':
description: Services updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
services: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-reviews/batch:
post:
x-resource-group: "engagement"
operationId: batchGetGoogleBusinessReviews
tags: [GMB Reviews]
summary: Batch get reviews
description: |
Fetches reviews across multiple locations in a single request.
More efficient than calling GET /gmb-reviews per location for multi-location businesses.
Returns a flat locationReviews array (not grouped by location): each item carries
the location resource name it belongs to (`name`) plus the review object (`review`),
whose identity is `review.reviewId`.
Reviews are requested from Google ordered by `orderBy` (default `updateTime desc`,
newest first), so callers polling for recent reviews can stop paginating once they
cross their date window.
Note: this endpoint does not return aggregate metrics (averageRating / totalReviewCount).
For those, use the single-location GET /gmb-reviews endpoint.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [locationNames]
properties:
locationNames:
type: array
minItems: 1
maxItems: 50
items: { type: string, pattern: '^accounts/[^/]+/locations/[^/]+$' }
description: "Array of full location resource names (e.g. ['accounts/123/locations/456']). Max 50 per request (Google's batchGetReviews cap); chunk larger sets into multiple requests."
pageSize:
type: integer
minimum: 1
maximum: 50
default: 50
description: Number of reviews per page (max 50)
pageToken:
type: string
minLength: 1
description: Pagination token from previous response
orderBy:
type: string
enum: ['updateTime desc', 'rating', 'rating desc']
default: 'updateTime desc'
description: "Sort order requested from Google. Defaults to 'updateTime desc' (newest first), which allows early-stopping pagination once results cross your date window."
example:
locationNames: ["accounts/123/locations/456", "accounts/123/locations/789"]
pageSize: 50
responses:
'200':
description: Batch reviews fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationReviews:
type: array
items:
type: object
properties:
name: { type: string, description: 'LOCATION resource name the review belongs to (accounts/{accountId}/locations/{locationId}) - NOT the review resource name. Use it to attribute the review to a location; the review identity is review.reviewId (full review resource name at review.name).' }
review: { type: object, description: "The review object: reviewId (the review's identity), name (full review resource name, accounts/*/locations/*/reviews/*), starRating, comment, reviewer, createTime, updateTime, reviewReply, and reviewMediaItems (review photos/videos; photo items carry thumbnailUrl, video items carry videoUrl)" }
nextPageToken: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-reviews/{reviewId}:
get:
x-resource-group: "engagement"
operationId: getGoogleBusinessReview
tags: [GMB Reviews]
summary: Get a review
description: |
Returns one Google Business 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 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-GBP account, or account missing location metadata
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "comment is required and must be a non-empty string"
'401':
description: Unauthorized or token invalid (account must be reconnected)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to post reply
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
x-resource-group: "engagement"
operationId: deleteGoogleBusinessReviewReply
tags: [GMB Reviews]
summary: Delete a review reply
description: Removes the business owner reply from a Google Business review. The review itself remains.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: reviewId
in: path
required: true
schema: { type: string }
description: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name
security:
- bearerAuth: []
responses:
'200':
description: Reply deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
platform: { type: string, example: googlebusiness }
example:
success: true
message: "Reply deleted successfully"
platform: "googlebusiness"
'400':
description: Invalid request, non-GBP account, or account missing location metadata
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token invalid (account must be reconnected)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to delete reply
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/connect/pending-data:
get:
x-resource-group: "public"
operationId: getPendingOAuthData
tags: [Connect]
summary: Get pending OAuth data
description: |
Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL.
**Scope**: This endpoint is used for LinkedIn organizations, Snapchat profiles, and Pinterest boards, where the selection list is too large to fit in URL params. The redirect carries a `pendingDataToken` instead of the full payload; the response includes the corresponding selection array (e.g. `boards` for Pinterest). WhatsApp, Facebook, Google Business and other platforms pass selection state directly via URL query params on the redirect (`profileId`, `tempToken`, `step`), no pending record is created, so this endpoint will return 404 for those flows. Use the platform-specific selection endpoint instead (e.g. `/v1/connect/whatsapp/select-phone-number`).
Token is one-time use and expires after 10 minutes. No authentication required.
parameters:
- name: token
in: query
required: true
schema: { type: string }
description: The pending data token from the OAuth redirect URL (pendingDataToken parameter)
responses:
'200':
description: OAuth data fetched successfully
content:
application/json:
schema:
type: object
properties:
platform:
type: string
description: The platform (e.g., "linkedin")
profileId:
type: string
description: The Zernio profile ID
tempToken:
type: string
description: Temporary access token for the platform
refreshToken:
type: string
description: Refresh token (if available)
expiresIn:
type: number
description: Token expiry in seconds
userProfile:
type: object
description: User profile data (id, username, displayName, profilePicture)
selectionType:
type: string
enum: [organizations, pages, boards, locations, profiles]
description: Type of selection data
organizations:
type: array
description: LinkedIn organizations (when selectionType is "organizations")
items:
type: object
properties:
id: { type: string }
urn: { type: string }
name: { type: string }
vanityName: { type: string }
example:
platform: "linkedin"
profileId: "abc123"
tempToken: "AQV..."
refreshToken: "AQW..."
expiresIn: 5183999
userProfile:
id: "ABC123"
username: "John Doe"
displayName: "John Doe"
profilePicture: "https://..."
selectionType: "organizations"
organizations:
- id: "12345"
urn: "urn:li:organization:12345"
name: "Acme Corp"
vanityName: "acme-corp"
- id: "67890"
urn: "urn:li:organization:67890"
name: "Example Inc"
vanityName: "example-inc"
'400':
description: Missing token parameter
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404':
description: Token not found or expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/connect/linkedin/organizations:
get:
x-resource-group: "public"
operationId: listLinkedInOrganizations
tags: [Connect]
summary: List LinkedIn orgs
description: Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth.
parameters:
- name: tempToken
in: query
required: true
schema: { type: string }
description: The temporary LinkedIn access token from the OAuth redirect
- name: orgIds
in: query
required: true
schema: { type: string }
description: Comma-separated list of organization IDs to fetch details for (max 100)
example: "12345678,87654321,11111111"
responses:
'200':
description: Organization details fetched successfully
content:
application/json:
schema:
type: object
properties:
organizations:
type: array
items:
type: object
properties:
id: { type: string, description: Organization ID }
logoUrl: { type: string, format: uri, description: Logo URL (may be absent if no logo) }
vanityName: { type: string, description: Organization's vanity name/slug }
website: { type: string, format: uri, description: Organization's website URL }
industry: { type: string, description: Organization's primary industry }
description: { type: string, description: Organization's description }
example:
organizations:
- id: "12345678"
logoUrl: "https://media.licdn.com/dms/image/v2/..."
vanityName: "acme-corp"
website: "https://acme.com"
industry: "Technology"
description: "Leading provider of innovative solutions"
- id: "87654321"
logoUrl: "https://media.licdn.com/dms/image/v2/..."
vanityName: "example-inc"
website: "https://example.com"
- id: "11111111"
'400':
description: Missing required parameters or too many organization IDs
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "Missing tempToken parameter"
'500':
description: Failed to fetch organization details
/v1/connect/linkedin/select-organization:
post:
x-resource-group: "accounts"
operationId: selectLinkedInOrganization
tags: [Connect]
summary: Select LinkedIn org
description: Complete the LinkedIn connection flow. Set accountType to "personal" or "organization" to connect as a company page. Use X-Connect-Token if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, tempToken, userProfile, accountType]
properties:
profileId: { type: string }
tempToken: { type: string }
userProfile: { type: object }
accountType: { type: string, enum: [personal, organization] }
selectedOrganization:
type: object
required: [id, urn, name]
properties:
id: { type: string, minLength: 1 }
urn: { type: string, pattern: '^urn:li:organization:' }
name: { type: string, minLength: 1 }
logoUrl: { type: string, format: uri }
vanityName: { type: string }
redirect_url: { type: string, format: uri }
examples:
personalAccount:
summary: Connect as personal LinkedIn profile
description: For personal accounts, set accountType to "personal" and omit selectedOrganization
value:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
tempToken: "AQX..."
userProfile:
id: "abc123"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/dms/image/v2/..."
accountType: "personal"
organizationAccount:
summary: Connect as org/company page
description: For organization pages, include the selectedOrganization object
value:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
tempToken: "AQX..."
userProfile:
id: "abc123"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/dms/image/v2/..."
accountType: "organization"
selectedOrganization:
id: "12345678"
urn: "urn:li:organization:12345678"
name: "Acme Corporation"
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: LinkedIn account connected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: The redirect URL with connection params appended (only if redirect_url was provided in request)
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [linkedin] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
accountType: { type: string, enum: [personal, organization] }
bulkRefresh:
type: object
properties:
updatedCount: { type: integer }
errors: { type: integer }
examples:
personalAccountResponse:
summary: Personal account connected
value:
message: "LinkedIn account connected successfully"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "linkedin"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/..."
isActive: true
accountType: "personal"
organizationWithRedirect:
summary: Org account with redirect URL
value:
message: "LinkedIn account connected successfully"
redirect_url: "https://yourapp.com/callback?connected=linkedin&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=Acme+Corporation"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "linkedin"
username: "acme-corp"
displayName: "Acme Corporation"
profilePicture: "https://media.licdn.com/..."
isActive: true
accountType: "organization"
bulkRefresh:
updatedCount: 5
errors: 0
'400': { description: Missing required fields }
'401': { $ref: '#/components/responses/Unauthorized' }
'500': { description: Failed to connect LinkedIn account }
/v1/connect/pinterest/select-board:
get:
x-resource-group: "accounts"
operationId: listPinterestBoardsForSelection
tags: [Connect]
summary: List Pinterest boards
description: For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL.
parameters:
- name: X-Connect-Token
in: header
required: true
schema: { type: string }
description: Short-lived connect token from the OAuth redirect
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Pinterest access token from the OAuth callback redirect
responses:
'200':
description: List of Pinterest Boards available for connection
content:
application/json:
schema:
type: object
properties:
boards:
type: array
items:
type: object
properties:
id: { type: string, description: Pinterest Board ID }
name: { type: string, description: Board name }
description: { type: string, description: Board description }
privacy: { type: string, description: Board privacy setting }
example:
boards:
- id: "123456789012345678"
name: "Marketing Ideas"
description: "Collection of marketing inspiration"
privacy: "PUBLIC"
- id: "234567890123456789"
name: "Product Photos"
description: "Product photography"
privacy: "PUBLIC"
'400': { description: Missing required parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to profile }
'500': { description: Failed to fetch boards }
post:
x-resource-group: "accounts"
operationId: selectPinterestBoard
tags: [Connect]
summary: Select Pinterest board
description: |
Complete the Pinterest connection flow. After OAuth, use this endpoint to save the selected board and complete the account connection. Use the X-Connect-Token header if you initiated the connection via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, boardId, tempToken]
properties:
profileId:
type: string
description: Your Zernio profile ID
boardId:
type: string
description: The Pinterest Board ID selected by the user
boardName:
type: string
description: The board name (for display purposes)
tempToken:
type: string
description: Temporary Pinterest access token from OAuth
userProfile:
type: object
description: User profile data from OAuth redirect
refreshToken:
type: string
description: Pinterest refresh token (if available)
expiresIn:
type: integer
description: Token expiration time in seconds
redirect_url:
type: string
format: uri
description: Custom redirect URL after connection completes
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
boardId: "123456789012345678"
boardName: "Marketing Ideas"
tempToken: "pina_..."
userProfile:
id: "user123"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://i.pinimg.com/..."
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: Pinterest Board connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url: { type: string, description: Redirect URL with connection params (if provided) }
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [pinterest] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
defaultBoardName: { type: string }
example:
message: "Pinterest connected successfully with default board"
redirect_url: "https://yourdomain.com/integrations/callback?connected=pinterest&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&board=Marketing+Ideas"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "pinterest"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://i.pinimg.com/..."
isActive: true
defaultBoardName: "Marketing Ideas"
'400':
description: Missing required fields
content:
application/json:
example:
error: "Missing required fields"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: No access to profile or profile limit exceeded
content:
application/json:
examples:
forbidden:
value: { error: "Forbidden" }
limitExceeded:
value:
error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
code: "PROFILE_LIMIT_EXCEEDED"
'500':
description: Failed to save Pinterest connection
/v1/connect/snapchat/select-profile:
get:
x-resource-group: "accounts"
operationId: listSnapchatProfiles
tags: [Connect]
summary: List Snapchat profiles
description: For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL.
parameters:
- name: X-Connect-Token
in: header
required: true
schema: { type: string }
description: Short-lived connect token from the OAuth redirect
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Snapchat access token from the OAuth callback redirect
responses:
'200':
description: List of Snapchat Public Profiles available for connection
content:
application/json:
schema:
type: object
properties:
publicProfiles:
type: array
items:
type: object
properties:
id: { type: string, description: Snapchat Public Profile ID }
display_name: { type: string, description: Public profile display name }
username: { type: string, description: Public profile username/handle }
profile_image_url: { type: string, description: Profile image URL }
subscriber_count: { type: integer, description: Number of subscribers }
example:
publicProfiles:
- id: "abc123-def456"
display_name: "My Brand"
username: "mybrand"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 15000
- id: "xyz789-uvw012"
display_name: "Side Project"
username: "sideproject"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 5000
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to profile }
'500': { description: Failed to fetch public profiles }
post:
x-resource-group: "accounts"
operationId: selectSnapchatProfile
tags: [Connect]
summary: Select Snapchat profile
description: Complete the Snapchat connection flow by saving the selected Public Profile. Snapchat requires a Public Profile to publish content. Use X-Connect-Token if connecting via API key.
parameters:
- name: X-Connect-Token
in: header
required: false
schema: { type: string }
description: Short-lived connect token from the OAuth redirect (for API users)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, selectedPublicProfile, tempToken, userProfile]
properties:
profileId:
type: string
description: Your Zernio profile ID
selectedPublicProfile:
type: object
description: The selected Snapchat Public Profile
required: [id, display_name]
properties:
id:
type: string
description: Snapchat Public Profile ID
display_name:
type: string
description: Display name of the public profile
username:
type: string
description: Username/handle
profile_image_url:
type: string
description: Profile image URL
subscriber_count:
type: integer
description: Number of subscribers
tempToken:
type: string
description: Temporary Snapchat access token from OAuth
userProfile:
type: object
description: User profile data from OAuth redirect
refreshToken:
type: string
description: Snapchat refresh token (if available)
expiresIn:
type: integer
description: Token expiration time in seconds
redirect_url:
type: string
format: uri
description: Custom redirect URL after connection completes
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
selectedPublicProfile:
id: "abc123-def456"
display_name: "My Brand"
username: "mybrand"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 15000
tempToken: "eyJ..."
userProfile:
id: "user123"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://cf-st.sc-cdn.net/..."
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: Snapchat Public Profile connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url: { type: string, description: Redirect URL with connection params (if provided in request) }
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [snapchat] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
publicProfileName: { type: string }
example:
message: "Snapchat connected successfully with public profile"
redirect_url: "https://yourdomain.com/integrations/callback?connected=snapchat&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&publicProfile=My+Brand"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "snapchat"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://cf-st.sc-cdn.net/..."
isActive: true
publicProfileName: "My Brand"
'400':
description: Missing required fields
content:
application/json:
example:
error: "Missing required fields"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: No access to profile or profile limit exceeded
content:
application/json:
examples:
forbidden:
value: { error: "Forbidden" }
limitExceeded:
value:
error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
code: "PROFILE_LIMIT_EXCEEDED"
'500':
description: Failed to connect Snapchat account
/v1/connect/bluesky/credentials:
post:
x-resource-group: "accounts"
operationId: connectBlueskyCredentials
tags: [Connect]
summary: Connect Bluesky account
description: |
Connect a Bluesky account using identifier (handle or email) and an app password.
To get your userId for the state parameter, call GET /v1/users which includes a currentUserId field.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [identifier, appPassword, state]
properties:
identifier:
type: string
description: Your Bluesky handle (e.g. user.bsky.social) or email address
appPassword:
type: string
description: App password generated from Bluesky Settings > App Passwords
state:
type: string
description: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles.
example: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri:
type: string
format: uri
description: Optional URL to redirect to after successful connection
example:
identifier: "yourhandle.bsky.social"
appPassword: "xxxx-xxxx-xxxx-xxxx"
state: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri: "https://yourapp.com/connected"
responses:
'200':
description: Bluesky connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account: { $ref: '#/components/schemas/SocialAccount' }
example:
message: "Bluesky connected successfully"
account:
platform: "bluesky"
username: "yourhandle.bsky.social"
displayName: "Your Name"
isActive: true
redirectUrl: "https://zernio.com/dashboard/profiles/64f0.../accounts"
'400': { description: Invalid request - missing fields or invalid state format }
'401': { $ref: '#/components/responses/Unauthorized' }
'500': { description: Internal error }
/v1/connect/openai-ads/credentials:
post:
x-resource-group: "accounts"
operationId: connectOpenAIAdsCredentials
tags: [Connect]
summary: Connect an OpenAI Ads account
description: |
Connect an OpenAI Ads account using an API key from ChatGPT Ads Manager.
The key grants full campaign write access on OpenAI's side (OpenAI does
not offer a read-only key scope). Zernio uses it to read ads and
performance, and to create and manage campaigns you set up through
Zernio (create, status, budget, and cancel). Campaigns created
directly in ChatGPT Ads Manager can still be managed there.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [apiKey, profileId]
properties:
apiKey:
type: string
description: "API key from ChatGPT Ads Manager (Settings). Grants full read/write access on OpenAI's side; Zernio only ever reads with it."
profileId:
type: string
description: Your Zernio profile ID
state:
type: string
description: Optional state passthrough for the connect flow.
redirectUri:
type: string
format: uri
description: Optional URL to redirect to after successful connection
example:
apiKey: "sk-ads-xxxxxxxxxxxxxxxxxxxx"
profileId: "6507a1b2c3d4e5f6a7b8c9d0"
responses:
'200':
description: OpenAI Ads connected successfully
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
adAccountName: { type: string }
redirectUrl: { type: string }
example:
accountId: "6507a1b2c3d4e5f6a7b8c9d0"
adAccountName: "Acme US"
'400': { $ref: '#/components/responses/BadRequest' }
'401':
description: "Unauthorized, or the API key could not read an OpenAI ad account (code invalid_credentials)."
'403':
description: Ads add-on required.
/v1/connect/whatsapp/credentials:
post:
x-resource-group: "accounts"
operationId: connectWhatsAppCredentials
tags: [Connect]
summary: Connect WhatsApp via credentials
description: |
Connect a WhatsApp Business Account by providing Meta credentials directly.
This is the headless alternative to the Embedded Signup browser flow.
To get the required credentials:
1. Go to Meta Business Suite (business.facebook.com)
2. Create or select a WhatsApp Business Account
3. In Business Settings > System Users, create a System User
4. Assign it the whatsapp_business_management and whatsapp_business_messaging permissions
5. Generate a permanent access token
6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers
7. Get the Phone Number ID from the same page (click on the number)
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 workspace (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. 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 workspace (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 Embedded Signup returns to your browser SDK. This is the headless completion path for WhatsApp: the code never passes through a redirect_uri, so POST /v1/connect/{platform} cannot accept it.'
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' }
responses:
'200': { description: Number connected }
'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 workspace' }
/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:
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}/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"
/v1/accounts/{accountId}/linkedin-post-reactions:
get:
x-resource-group: "analytics"
operationId: getLinkedInPostReactions
tags: [Analytics]
summary: Get LinkedIn post reactions
description: |
Returns individual reactions for a specific LinkedIn post, including reactor profiles
(name, headline/job title, profile picture, profile URL, reaction type).
Only works for organization/company page accounts. LinkedIn restricts reaction
data for personal profiles (r_member_social_feed is a closed permission).
parameters:
- name: accountId
in: path
required: true
description: The ID of the LinkedIn organization account
schema: { type: string }
- name: urn
in: query
required: true
description: The LinkedIn post URN
schema: { type: string, pattern: '^urn:li:(share|ugcPost|activity):[0-9]+$' }
example: "urn:li:share:7123456789012345678"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
description: Maximum number of reactions to return per page
- name: cursor
in: query
schema: { type: integer, minimum: 0, default: 0 }
description: Offset-based pagination start index
responses:
'200':
description: Reactions with reactor profiles
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: organization }
username: { type: string }
postUrn: { type: string }
reactions:
type: array
items:
type: object
properties:
reactionType:
type: string
description: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
reactionLabel:
type: string
description: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny)
reactedAt: { type: string, format: date-time }
from:
type: object
properties:
urn: { type: string, description: "LinkedIn person or organization URN" }
name: { type: string, description: "Reactor's display name" }
headline: { type: string, description: "Reactor's headline/job title" }
username: { type: string, description: "LinkedIn vanity name" }
profilePicture: { type: string, description: "Profile picture URL" }
profileUrl: { type: string, description: "Direct link to LinkedIn profile" }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: string, description: "Offset for next page" }
total: { type: integer, description: "Total number of reactions (when available)" }
lastUpdated: { type: string, format: date-time }
example:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "organization"
username: "Acme Corp"
postUrn: "urn:li:share:7123456789012345678"
reactions:
- reactionType: "LIKE"
reactionLabel: "Like"
reactedAt: "2026-03-08T12:00:00.000Z"
from:
urn: "urn:li:person:abc123"
name: "Jane Smith"
headline: "Product Manager at Acme Corp"
username: "janesmith"
profilePicture: "https://media.licdn.com/..."
profileUrl: "https://www.linkedin.com/in/janesmith"
pagination:
hasMore: true
cursor: "25"
total: 156
lastUpdated: "2026-03-08T12:00:00.000Z"
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'403':
description: Missing required LinkedIn scope
'404':
description: Account or post not found
/v1/accounts/{accountId}/linkedin-organization:
put:
x-resource-group: "accounts"
operationId: updateLinkedInOrganization
tags: [Connect]
summary: Switch LinkedIn account type
description: Switch a LinkedIn account between personal profile and organization (company page) posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountType]
properties:
accountType: { type: string, enum: [personal, organization] }
selectedOrganization: { type: object }
example:
accountType: "organization"
selectedOrganization:
id: "12345678"
urn: "urn:li:organization:12345678"
name: "Acme Corporation"
vanityName: "acme-corp"
responses:
'200':
description: Account updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
accountType: { type: string, enum: [personal, organization] }
accountName: { type: string }
example:
message: "LinkedIn account updated successfully"
accountType: "organization"
accountName: "Acme Corporation"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-mentions:
get:
x-resource-group: "accounts"
operationId: getLinkedInMentions
tags: [LinkedIn Mentions]
summary: Resolve LinkedIn mention
description: |
Converts a LinkedIn profile or company URL to a URN for @mentions in posts.
How to use LinkedIn @mentions (2-step workflow):
1. Call this endpoint with the LinkedIn profile/company URL to get the mention URN and format.
2. Embed the returned mentionFormat (e.g. @[Vincent Jong](urn:li:person:xxx)) directly in your post's content field.
Example:
- Resolve: GET /v1/accounts/{id}/linkedin-mentions?url=linkedin.com/in/vincentjong&displayName=Vincent Jong
- Returns: mentionFormat: "@[Vincent Jong](urn:li:person:xxx)"
- Use in post content: "Great talk with @[Vincent Jong](urn:li:person:xxx) today!"
Important: The mentions array field in POST /v1/posts is stored for reference only and does NOT trigger @mentions on LinkedIn. You must embed the mention format directly in the content text.
Requirements:
- Person mentions require the LinkedIn account to be admin of at least one organization. This is a LinkedIn API limitation: the only endpoints that resolve profile URLs to member URNs (vanityUrl, peopleTypeahead) are scoped to organization followers. There is no public LinkedIn API to resolve a vanity URL without organization context.
- Organization mentions (e.g. @Microsoft) work without this requirement.
- For person mentions to be clickable, the displayName parameter must exactly match the name shown on their LinkedIn profile.
- Person mentions DO work when published from personal profiles (the URN just needs to be valid). The limitation is only in the resolution step (URL to URN), not in publishing.
parameters:
- name: accountId
in: path
required: true
description: The LinkedIn account ID
schema: { type: string }
- name: url
in: query
required: true
description: LinkedIn profile URL, company URL, or vanity name.
schema: { type: string }
examples:
personVanityName:
value: "miquelpalet"
summary: Person - just the vanity name
personFullUrl:
value: "https://www.linkedin.com/in/miquelpalet"
summary: Person - full LinkedIn URL
orgShortUrl:
value: "company/microsoft"
summary: Org - short format
orgFullUrl:
value: "https://www.linkedin.com/company/microsoft"
summary: Org - full LinkedIn URL
- name: displayName
in: query
required: false
description: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions.
schema: { type: string }
examples:
personName:
value: "Miquel Palet"
summary: Exact name as shown on LinkedIn profile
orgName:
value: "Microsoft"
summary: Company name (optional for orgs)
responses:
'200':
description: URN resolved successfully
content:
application/json:
schema:
type: object
properties:
urn:
type: string
description: The LinkedIn URN (person or organization)
example: "urn:li:person:4qj5ox-agD"
type:
type: string
enum: [person, organization]
description: The type of entity (person or organization)
example: "person"
displayName:
type: string
description: Display name (provided, from API, or derived from vanity URL)
example: "Miquel Palet"
mentionFormat:
type: string
description: Ready-to-use mention format for post content
example: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
vanityName:
type: string
description: The vanity name/slug (only for organization mentions)
example: "microsoft"
warning:
type: string
description: Warning about clickable mentions (only present for person mentions if displayName was not provided)
example: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
examples:
personWithDisplayName:
summary: Person mention with displayName (recommended)
value:
urn: "urn:li:person:4qj5ox-agD"
type: "person"
displayName: "Miquel Palet"
mentionFormat: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
personWithoutDisplayName:
summary: Person mention without displayName (may not be clickable)
value:
urn: "urn:li:person:4qj5ox-agD"
type: "person"
displayName: "Miquelpalet"
mentionFormat: "@[Miquelpalet](urn:li:person:4qj5ox-agD)"
warning: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
organization:
summary: Org mention
value:
urn: "urn:li:organization:1035"
type: "organization"
displayName: "Microsoft"
mentionFormat: "@[Microsoft](urn:li:organization:1035)"
vanityName: "microsoft"
'400':
description: Invalid request or no organization found (for person mentions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
missingUrl:
value: { error: "url parameter is required" }
noOrgForPersonMention:
value: { error: "No organization found. You need to be an admin of a LinkedIn organization to use person mentions. Organization mentions work without this requirement." }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: The account holds no organization role that can resolve person mentions
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
noPostingRole:
value: { error: "LinkedIn blocked this lookup. Your account has no company page where it can create posts, which LinkedIn requires to resolve a person's profile URL. Ask for an Admin or Content Admin role on one of your pages, or mention a company instead." }
'404':
description: Person or organization not found
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
memberNotFound:
value: { error: "Member not found. Check the LinkedIn URL is correct." }
orgNotFound:
value: { error: "Organization not found. Check the LinkedIn company URL is correct." }
/v1/accounts/{accountId}/instagram/stories:
get:
x-resource-group: "analytics"
operationId: listInstagramStories
tags: [Instagram]
summary: List active Instagram stories
description: |
Returns the IG Business/Creator account's currently-active stories.
Meta keeps stories live for 24h; expired stories are not returned.
Limitations propagated from Meta (these are NOT bugs):
- 24h window only
- Live videos excluded
- Reshared stories not returned
- `mediaUrl` may be null if Meta flagged the story for copyright
- `caption`, `likeCount`, `commentsCount` do not apply to story media
parameters:
- name: accountId
in: path
required: true
description: The Instagram account ID
schema: { type: string }
responses:
'200':
description: Active stories
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items:
type: object
required: [id]
properties:
id: { type: string, description: Instagram media ID of the story. }
mediaType: { type: [string, "null"], description: "IMAGE / VIDEO / CAROUSEL_ALBUM" }
mediaProductType: { type: [string, "null"], description: Always 'STORY' for this endpoint. }
mediaUrl: { type: [string, "null"], description: Direct media URL. Null if Meta flagged the story for copyright. URL expires when the story expires. }
permalink: { type: [string, "null"], description: Public Instagram permalink to the story (only viewable while live). }
thumbnailUrl: { type: [string, "null"], description: Thumbnail URL for video stories. }
timestamp: { type: [string, "null"], format: date-time, description: When the story was posted. }
'400': { description: Invalid request. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Instagram account not found. }
/v1/accounts/{accountId}/instagram/publishing-limit:
get:
x-resource-group: "accounts"
operationId: getInstagramPublishingLimit
tags: [Instagram]
summary: Get Instagram publishing limit
description: |
Returns the account's remaining content-publishing quota for Instagram's rolling
24-hour window, so you can pace publishing and warn before the cap is reached.
`quotaUsage` counts containers published since the start of the window.
Always compare against the returned `quotaTotal` rather than hardcoding a number:
Meta's prose documentation and the live API disagree on the value, and the live
value is authoritative.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Instagram account
schema: { type: string }
responses:
'200':
description: Remaining publishing quota for the rolling window
content:
application/json:
schema:
type: object
properties:
quotaUsage: { type: integer, description: Containers published so far in the current window }
quotaTotal: { type: integer, description: Maximum containers publishable per window }
quotaDurationSeconds: { type: integer, description: Length of the rolling window in seconds }
example:
quotaUsage: 3
quotaTotal: 50
quotaDurationSeconds: 86400
'400': { description: Not an Instagram account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: Instagram rejected the request }
/v1/accounts/{accountId}/instagram/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:
'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': { description: Account not found }
'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:
'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': { description: Account not found }
'502': { description: Instagram rejected the request }
/v1/accounts/{accountId}/instagram/stories/{storyId}/insights:
get:
x-resource-group: "analytics"
operationId: getInstagramStoryInsights
tags: [Instagram]
summary: Get Instagram story insights
description: |
Returns metrics for a single story. The `source` field discriminates
between three states:
- `live` — fetched from Meta in real time (story is still active)
- `cached` — fetched from a persisted `story_insights` webhook payload
(story has expired but we received its final-state metrics from Meta)
- `unavailable` — story has expired and we never received its webhook
payload (for example, the account connected after the story expired)
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:
'200':
description: Story insights
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: object
required: [source, metrics]
properties:
source:
type: string
enum: [live, cached, unavailable]
metrics:
type: object
required:
[views, reach, replies, shares, navigation, tapsForward,
tapsBack, exits, swipesForward, profileVisits, follows,
reposts, totalInteractions]
properties:
views: { type: integer, description: "Total story plays. Replaces deprecated 'impressions' for media created after 2024-07-02." }
reach: { type: integer, description: Unique accounts that saw the story. }
replies: { type: integer, description: DMs sent in reply to the story. }
shares: { type: integer }
navigation: { type: integer, description: Total nav actions (tapsForward + tapsBack + exits + swipesForward). }
tapsForward: { type: integer, description: Tapped right to next slide of SAME story. }
tapsBack: { type: integer, description: Tapped left to previous slide. }
exits: { type: integer, description: Closed Stories interface entirely. }
swipesForward: { type: integer, description: "Swiped left to next account's story." }
profileVisits: { type: integer }
follows: { type: integer }
reposts: { type: integer }
totalInteractions: { type: integer }
'400': { description: Invalid request. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Instagram account not found. }
'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-playlists:
get:
x-resource-group: "accounts"
operationId: getYoutubePlaylists
tags: [Connect]
summary: List YouTube playlists
description: Returns the playlists available for a connected YouTube account. Use this to get a playlist ID when creating a YouTube post with the playlistId field.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Playlists list
content:
application/json:
schema:
type: object
properties:
playlists:
type: array
items:
type: object
properties:
id: { type: string }
title: { type: string }
description: { type: string }
privacy: { type: string, enum: [public, private, unlisted] }
itemCount: { type: integer }
thumbnailUrl: { type: string }
defaultPlaylistId:
type: [string, "null"]
example:
playlists:
- id: "PLxxxxxxxxxxxxx"
title: "Tutorials"
description: "Step-by-step video tutorials"
privacy: "public"
itemCount: 24
thumbnailUrl: "https://i.ytimg.com/vi/xxx/mqdefault.jpg"
- id: "PLyyyyyyyyyyyyy"
title: "Vlogs"
description: "Weekly vlogs"
privacy: "public"
itemCount: 52
thumbnailUrl: "https://i.ytimg.com/vi/yyy/mqdefault.jpg"
defaultPlaylistId: null
'400': { description: Not a YouTube account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
x-resource-group: "accounts"
operationId: updateYoutubeDefaultPlaylist
tags: [Connect]
summary: Set default YouTube playlist
description: Sets the default playlist used when publishing videos for this account. When a post does not specify a playlistId, the default playlist is not automatically used (it is stored for client-side convenience).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultPlaylistId]
properties:
defaultPlaylistId: { type: string, minLength: 1 }
defaultPlaylistName: { type: string }
example:
defaultPlaylistId: "PLxxxxxxxxxxxxx"
defaultPlaylistName: "Tutorials"
responses:
'200':
description: Default playlist set
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/gmb-locations:
get:
x-resource-group: "accounts"
operationId: getGmbLocations
tags: [Connect]
summary: List GBP locations
description: >
Returns Google Business Profile locations the connected account can access,
plus the currently selected location. The list is bounded (see hasMore); for
accounts that own many locations, use the search or filter query params to find
a specific one instead of loading them all, or raise limit to enumerate an
account with more than 100 locations.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: search
in: query
required: false
schema: { type: string }
description: Free-text search on the business name, applied server-side by Google. Use for accounts with many locations.
- name: filter
in: query
required: false
schema: { type: string }
description: Raw Google Business Information API filter expression (advanced; takes precedence over search), e.g. storeCode="LH279411".
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
description: Max locations to return (default 100, max 500). Raise it to enumerate an account with more than 100 locations; for accounts with thousands, use search/filter instead.
responses:
'200':
description: Locations list
content:
application/json:
schema:
type: object
properties:
locations:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
accountId: { type: string }
accountName: { type: string }
address: { type: string }
category: { type: string }
websiteUrl: { type: string }
storeCode: { type: string }
hasMore:
type: boolean
description: True when more locations exist than were returned (use search to narrow down).
selectedLocationId: { type: string }
cached: { type: boolean }
example:
locations:
- id: "12345678901234567890"
name: "My Business Location"
accountId: "accounts/123456789"
accountName: "My Business Account"
address: "123 Main St, San Francisco, CA"
category: "Restaurant"
websiteUrl: "https://mybusiness.com"
selectedLocationId: "12345678901234567890"
cached: true
'400': { description: Invalid query parameter (e.g. limit out of range) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
x-resource-group: "accounts"
operationId: updateGmbLocation
tags: [Connect]
summary: Update GBP location
description: Switch which GBP location is active for a connected account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [selectedLocationId]
properties:
selectedLocationId: { type: string, minLength: 1 }
googleAccountId:
type: string
minLength: 1
description: >
Optional but recommended. The Google Business Account resource name
("accounts/123") that owns the new location (from GET gmb-locations).
When provided, the location is resolved directly instead of by
enumerating the account, which is required for accounts with many
locations. Named `googleAccountId` to disambiguate from the path
`accountId` (the Zernio account). The legacy field name `accountId`
is still accepted for backwards compatibility.
example:
selectedLocationId: "12345678901234567890"
googleAccountId: "accounts/123456789"
responses:
'200':
description: Location updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
selectedLocation:
type: object
properties:
id: { type: string }
name: { type: string }
example:
message: "Google Business location updated successfully"
selectedLocation:
id: "12345678901234567890"
name: "My Business Location"
'400': { description: 'Location not in available locations, or the provided googleAccountId is not one of the accounts this connection manages' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/gmb-locations/assign:
post:
x-resource-group: "accounts"
operationId: assignGoogleBusinessLocation
tags: [Connect]
summary: Assign GBP location to another profile
description: >
Connect a Google Business location onto a DIFFERENT profile by reusing the
OAuth grant from an already-connected GBP account — no browser, no
re-authorization. Built for agencies whose single Google account has manager
access to many client locations and who run one profile per client: connect
one location the normal way (browser OAuth), then bulk-assign the rest onto
each client's profile via this endpoint. The path `accountId` is a SOURCE
connected GBP account (the token holder); the body `profileId` is the TARGET
profile. Returns 409 if the target profile already has a Google Business
connection (switch its location with PUT gmb-locations instead).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: A source connected GBP account whose OAuth grant is reused.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, selectedLocationId]
properties:
profileId:
type: string
description: Target profile to connect the location onto.
selectedLocationId:
type: string
description: The Google Business location ID to assign (e.g. "locations/123").
googleAccountId:
type: string
description: >
Optional but recommended. The Google Business Account resource name
("accounts/123") that owns the location (from GET gmb-locations). When
provided the location is resolved directly instead of by enumerating the
account, required for accounts with many locations.
example:
profileId: "507f1f77bcf86cd799439011"
selectedLocationId: "locations/12345678901234567890"
googleAccountId: "accounts/123456789"
responses:
'200':
description: Location assigned to the target profile
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account:
type: object
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
isActive: { type: boolean }
selectedLocationName: { type: string, description: 'Human-readable location display name (e.g. "Snap Fitness Dianella"), NOT a resource name. Do not use it to build API paths.' }
selectedLocationId: { type: string, description: 'Bare GBP location id (digits only). Combine with the GBP account id as accounts/{gbpAccountId}/locations/{selectedLocationId} to form the location resource names that gmb-reviews/batch expects in locationNames.' }
example:
message: "Google Business location assigned to profile successfully"
account:
accountId: "6a41f0ab70e2d329a7741307"
platform: "googlebusiness"
username: "Snap Fitness Dianella"
displayName: "Snap Fitness Dianella"
isActive: true
selectedLocationName: "Snap Fitness Dianella"
selectedLocationId: "12345678901234567890"
'400': { description: 'Invalid body, selected location not found under the Google account, or the provided googleAccountId is not one of the accounts this connection manages' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Payment required, or target profile exceeds plan limit' }
'404': { description: Source Google Business account not found }
'409': { description: 'Target profile already has a Google Business connection (use PUT gmb-locations to switch its location)' }
/v1/accounts/{accountId}/facebook-post-reactions:
get:
x-resource-group: "analytics"
operationId: getFacebookPostReactions
tags: [Analytics]
summary: Get Facebook post reactions
description: |
Returns the reaction breakdown for a Facebook Page post: a count per reaction type
plus the overall total.
The whole breakdown is fetched in a single Graph call. Note that the post analytics
endpoint reports only an aggregate reaction count (surfaced there as `likes`), so use
this endpoint when you need per-type counts.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Facebook Page account
schema: { type: string }
- name: postId
in: query
required: true
description: The Facebook post ID
schema: { type: string }
example: "1234567890_9876543210"
responses:
'200':
description: Reaction breakdown for the post
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: facebook }
username: { type: string }
postId: { type: string }
total: { type: integer, description: Total reactions across all types }
breakdown:
type: object
description: Count per reaction type. A type with no reactions returns 0.
properties:
like: { type: integer }
love: { type: integer }
haha: { type: integer }
wow: { type: integer }
sad: { type: integer }
angry: { type: integer }
care: { type: integer }
lastUpdated: { type: string, format: date-time }
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "facebook"
username: "My Page"
postId: "1234567890_9876543210"
total: 28
breakdown: { like: 10, love: 5, haha: 4, wow: 3, sad: 2, angry: 1, care: 3 }
lastUpdated: "2026-07-09T09:43:50.000Z"
'400': { description: 'Invalid accountId format, not a Facebook account, or missing postId parameter' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: Facebook rejected the request }
/v1/accounts/{accountId}/reddit-subreddits:
get:
x-resource-group: "accounts"
operationId: getRedditSubreddits
tags: [Connect]
summary: List Reddit subreddits
description: Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Subreddits list
content:
application/json:
schema:
type: object
properties:
subreddits:
type: array
items:
type: object
properties:
id: { type: string, description: Reddit subreddit ID }
name: { type: string, description: Subreddit name without r/ prefix }
title: { type: string, description: Subreddit title }
url: { type: string, description: Subreddit URL path }
over18: { type: boolean, description: Whether the subreddit is NSFW }
defaultSubreddit:
type: string
description: Currently set default subreddit for posting
example:
subreddits:
- id: "2qh1i"
name: "marketing"
title: "Marketing"
url: "/r/marketing/"
over18: false
- id: "2qh3l"
name: "socialmedia"
title: "Social Media"
url: "/r/socialmedia/"
over18: false
defaultSubreddit: "marketing"
'400': { description: Not a Reddit account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
x-resource-group: "accounts"
operationId: updateRedditSubreddits
tags: [Connect]
summary: Set default subreddit
description: Sets the default subreddit used when publishing posts for this Reddit account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultSubreddit]
properties:
defaultSubreddit: { type: string, minLength: 1 }
example:
defaultSubreddit: "marketing"
responses:
'200':
description: Default subreddit set
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/reddit-subreddits/{subreddit}/rules:
get:
x-resource-group: "accounts"
operationId: getSubredditRules
tags: [Connect]
summary: Get subreddit rules
description: |
Returns a subreddit's posting rules plus Reddit's site-wide rules, so you can check
them before submitting and avoid a removal.
Use this alongside `POST /v1/tools/validate/subreddit`, which only confirms that a
subreddit exists and reports its basic posting settings.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account
schema: { type: string }
- name: subreddit
in: path
required: true
description: Subreddit name (without the "r/" prefix)
schema: { type: string }
example: "webdev"
responses:
'200':
description: Subreddit and site rules
content:
application/json:
schema:
type: object
properties:
rules:
type: array
items:
type: object
properties:
kind: { type: string, description: "Scope of the rule: 'link', 'comment', or 'all'" }
shortName: { type: string, description: Short rule title shown in the subreddit sidebar }
description: { type: string, description: Full rule text }
violationReason: { type: string, description: Reason shown to a user when the rule is enforced }
createdUtc: { type: number, description: Unix timestamp when the rule was created }
priority: { type: integer, description: Display order of the rule }
siteRules:
type: array
description: Reddit's site-wide content policy rules
items: { type: string }
example:
rules:
- kind: "link"
shortName: "No self-promotion"
description: "Posts that primarily promote your own product will be removed."
violationReason: "Self-promotion"
createdUtc: 1611234567
priority: 0
siteRules:
- "Spam"
- "Personal and confidential information"
'400': { description: Not a Reddit account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account or subreddit not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/accounts/{accountId}/reddit-vote:
post:
x-resource-group: "engagement"
operationId: voteRedditThing
tags: [Connect]
summary: Vote on a Reddit post or comment
description: |
Cast, change, or clear the connected account's vote on a Reddit post or comment.
**Reddit requires that votes be cast by humans.** Reddit's API terms permit a client
to proxy a human's action one-for-one, and prohibit a bot from deciding how to vote
or from amplifying a human's vote. Call this endpoint only in direct response to an
explicit action by the account owner. Automated or agent-decided voting is
vote manipulation and puts API access at risk.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account casting the vote
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [thingId, direction]
properties:
thingId:
type: string
description: >
Reddit fullname of the target. Prefix "t3_" for a post and "t1_" for a
comment. A bare id with no prefix is treated as a post ("t3_").
example: "t3_abc123"
direction:
type: integer
description: "1 to upvote, -1 to downvote, 0 to clear an existing vote"
enum: [1, 0, -1]
example:
thingId: "t3_abc123"
direction: 1
responses:
'200':
description: Vote registered
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Not a Reddit account, or invalid thingId/direction" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/accounts/{accountId}/reddit-flairs:
get:
x-resource-group: "accounts"
operationId: getRedditFlairs
tags: [Connect]
summary: List subreddit flairs
description: Returns available post flairs for a subreddit. Some subreddits require a flair when posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: subreddit
in: query
required: true
schema: { type: string, minLength: 1 }
description: Subreddit name (without "r/" prefix) to fetch flairs for
responses:
'200':
description: Flairs list
content:
application/json:
schema:
type: object
properties:
flairs:
type: array
items:
type: object
properties:
id: { type: string, description: Flair ID to pass as flairId in platformSpecificData }
text: { type: string, description: Flair display text }
textColor: { type: string, description: "Text color: 'dark' or 'light'" }
backgroundColor: { type: string, description: "Background hex color (e.g. '#ff4500')" }
example:
flairs:
- id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
text: "Discussion"
textColor: "dark"
backgroundColor: "#edeff1"
- id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
text: "News"
textColor: "light"
backgroundColor: "#ff4500"
'400': { description: Not a Reddit account or missing subreddit parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
post:
x-resource-group: "accounts"
operationId: setRedditPostFlair
tags: [Connect]
summary: Set Reddit post flair
description: |
Applies a flair to a post the connected account already published. Use the GET on this
path to list the available `flairTemplateId` values for the subreddit.
Flair can also be set at submit time by passing `flairId` in `platformSpecificData`
when creating the post. This endpoint is for changing it afterwards.
The subreddit must allow users to select their own post flair. Setting flair on
another user's post requires moderator permissions, which Zernio does not request.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account that owns the post
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [subreddit, postId, flairTemplateId]
properties:
subreddit: { type: string, minLength: 1, description: Subreddit name (without the "r/" prefix) }
postId: { type: string, minLength: 1, description: 'Reddit post id, with or without the t3_ prefix' }
flairTemplateId: { type: string, minLength: 1, description: Flair template id from the GET on this path }
text: { type: string, description: "Optional override text, only for editable flair templates" }
example:
subreddit: "webdev"
postId: "t3_abc123"
flairTemplateId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
responses:
'200':
description: Flair applied
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Not a Reddit account, or missing subreddit/postId/flairTemplateId" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: "Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses (e.g. subreddit does not allow user flair selection) are forwarded as-is." }
/v1/accounts/{accountId}/slack-settings:
get:
x-resource-group: "accounts"
operationId: getSlackSettings
tags: [Accounts]
summary: Get Slack account settings
description: 'Returns the connected Slack channel details and the default message identity (name and avatar shown as the author on every post, with Slack''s APP badge). The identity applies to messages only; the app''s own Slack profile is global and cannot be changed per workspace.'
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Slack account settings
content:
application/json:
schema:
type: object
properties:
account:
type: object
properties:
_id: { type: string }
platform: { type: string, example: slack }
displayName: { type: [string, "null"] }
channelId: { type: [string, "null"] }
channelName: { type: [string, "null"] }
channelType: { type: [string, "null"], description: 'public or private' }
teamId: { type: [string, "null"] }
teamName: { type: [string, "null"] }
defaultUsername: { type: [string, "null"] }
defaultIconUrl: { type: [string, "null"] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
patch:
x-resource-group: "accounts"
operationId: updateSlackSettings
tags: [Accounts]
summary: Update Slack account settings
description: 'Set or clear the default message identity for this channel. Empty string clears a field; per-post platformSpecificData.username/iconUrl still override these defaults.'
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
defaultUsername: { type: string, maxLength: 80, description: 'Author name shown on posts. Empty string clears it.' }
defaultIconUrl: { type: string, description: 'Author avatar image URL. Empty string clears it.' }
responses:
'200':
description: Updated settings
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/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 — uses Discord's @me path for DMs" }
timestamp: { type: string, format: date-time }
recipient:
type: object
properties:
userId: { type: string }
platform: { type: string, example: discord }
account:
type: object
properties:
id: { type: string }
username: { type: string }
displayName: { type: string }
example:
messageId: "1357924680135792468"
channelId: "1357924680135792467"
url: "https://discord.com/channels/@me/1357924680135792467/1357924680135792468"
timestamp: "2026-06-02T12:34:56.789Z"
recipient: { userId: "1234567890123456789", platform: "discord" }
account: { id: "65a1b2c3d4e5f60718293a4b", username: "announcements", displayName: "My Server - #announcements" }
'400': { description: "Validation error (missing required fields, content > 2000 chars, malformed snowflake, or all of content/embeds/attachments missing)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not accessible to this user. }
'403': { description: "Discord refused the message (most commonly: bot doesn't share a guild with the recipient, OR the recipient has DMs disabled). Error body contains Discord's response." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/roles:
get:
x-resource-group: "engagement"
operationId: listDiscordGuildRoles
tags: [Discord]
summary: List Discord guild roles
description: |
Returns all roles in a Discord guild. Useful for building role-mention
pickers, role-permission UIs, or finding the role ID before calling
the role-assign endpoint.
Roles are returned unordered — sort client-side by `position` if you
need Discord's UI ordering.
Caller must pass `accountId` of a Discord SocialAccount bound to this
guild (route verifies team access + guild match).
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
responses:
'200':
description: List of guild roles.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string, description: Role snowflake ID }
name: { type: string }
color: { type: integer, description: Decimal color (0 = no color). Convert to hex via .toString(16). }
position: { type: integer, description: Position in role hierarchy (higher = more authority) }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
managed: { type: boolean, description: True for integration-managed roles (bot roles) }
mentionable: { type: boolean }
hoist: { type: boolean, description: True if role is displayed separately in member list }
'400': { description: Invalid accountId or guildId format. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: Discord refused the request (bot lacks View Channels permission in the guild). }
'502': { description: Discord was unreachable or returned an unclassified error. }
post:
x-resource-group: "engagement"
operationId: createDiscordGuildRole
tags: [Discord]
summary: Create a Discord guild role
description: |
Creates a new role in the guild.
Requires the bot to hold the Manage Roles permission. Guilds that added the Zernio bot
before role management shipped must re-invite it, because Discord applies the
permission set at invite time.
Discord's role hierarchy applies: the bot cannot create a role positioned at or above
its own highest role, and cannot grant permissions it does not itself hold. Either
attempt returns a 403 carrying Discord's own error.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
color: { type: integer, minimum: 0, maximum: 16777215, description: "Decimal color (0 = no color). 0xFF0000 red is 16711680." }
hoist: { type: boolean, description: Display members with this role separately in the member list }
mentionable: { type: boolean, description: Allow anyone to @mention this role }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
example:
name: "Moderators"
color: 16711680
hoist: true
mentionable: false
responses:
'201':
description: Role created.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordRole' }
'400': { description: "Invalid accountId, guildId, or role body." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the new role would sit at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/roles/{roleId}:
patch:
x-resource-group: "engagement"
operationId: editDiscordGuildRole
tags: [Discord]
summary: Edit a Discord guild role
description: |
Updates a role's name, color, hoist, mentionable flag, or permission bitfield.
At least one field must be supplied. Omitted fields are left unchanged.
Requires the bot to hold Manage Roles, and the target role must sit below the bot's
highest role. See the create-role operation for the re-invite requirement.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
minProperties: 1
properties:
name: { type: string, minLength: 1, maxLength: 100 }
color: { type: integer, minimum: 0, maximum: 16777215 }
hoist: { type: boolean }
mentionable: { type: boolean }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
example:
name: "Senior Moderators"
mentionable: true
responses:
'200':
description: Role updated.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordRole' }
'400': { description: "Invalid ids, or no fields supplied to edit." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "engagement"
operationId: deleteDiscordGuildRole
tags: [Discord]
summary: Delete a Discord guild role
description: |
Permanently deletes a role from the guild and removes it from every member.
This cannot be undone.
Requires the bot to hold Manage Roles, and the target role must sit below the bot's
highest role.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
responses:
'200':
description: Role deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Invalid accountId, guildId, or roleId format." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/members:
get:
x-resource-group: "contacts"
operationId: listDiscordGuildMembers
tags: [Discord]
summary: List Discord guild members
description: |
Cursor-paginated list of guild members. Returns Discord's raw member
objects so callers can build community-ops automation (e.g. "add role
to all members joined in the last 7 days") on the actual platform shape.
Pagination: pass `after` = the last `user.id` from the previous page.
Omit on the first call. Response includes a `nextCursor` and `hasMore`
flag so callers don't need to know Discord's pagination shape.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }, description: Page size (1-1000). }
- { name: after, in: query, required: false, schema: { type: string }, description: Snowflake of the last member from the previous page. }
responses:
'200':
description: List of guild members.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/DiscordGuildMember' }
pagination:
type: object
properties:
nextCursor: { type: [string, "null"], description: Pass as `after` on the next call. Null when there are no more pages. }
hasMore: { type: boolean }
'400': { description: Invalid query params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Discord denied access to the guild members (the bot is no longer in the guild).' }
'404': { description: Discord account not found or not in this guild. }
/v1/discord/guilds/{guildId}/members/search:
get:
x-resource-group: "contacts"
operationId: searchDiscordGuildMembers
tags: [Discord]
summary: Search Discord guild members
description: |
Search guild members whose username or nickname **starts with** the
query (Discord matches prefixes only, not substrings).
Cheaper than paginating the full member listing when you already know
who you are looking for.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: query, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 100 }, description: Username or nickname prefix to match. }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 25 } }
responses:
'200':
description: Matching guild members.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/DiscordGuildMember' }
'400': { description: Invalid query params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
/v1/discord/guilds/{guildId}/members/{userId}:
get:
x-resource-group: "contacts"
operationId: getDiscordGuildMember
tags: [Discord]
summary: Get a Discord guild member
description: |
Fetch a single guild member by Discord user id.
Cheaper than paginating the full member listing when you already know
who you are looking for.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake. }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: The guild member.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordGuildMember' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: 'Discord account not found, or the user is not a member of this guild.' }
/v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}:
put:
x-resource-group: "contacts"
operationId: addDiscordMemberRole
tags: [Discord]
summary: Assign a role to a guild member
description: |
Assign one role to one member. Idempotent on Discord's side — re-running
on a member who already has the role is a 204 no-op.
Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`)
for zero-translation mental mapping.
Bot needs MANAGE_ROLES permission in the guild AND its highest role
must be above the target role (Discord hierarchy rule). The
`@everyone` role (where roleId == guildId) cannot be assigned.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake to assign the role to. }
- { name: roleId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Role assigned (or already present — idempotent).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [role_assigned] }
guildId: { type: string }
userId: { type: string }
roleId: { type: string }
'400': { description: "Validation error (malformed snowflake) or @everyone manipulation attempt." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'403': { description: "Discord refused the request: bot lacks MANAGE_ROLES, or target role is at or above the bot's highest role." }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "contacts"
operationId: removeDiscordMemberRole
tags: [Discord]
summary: Remove a role from a guild member
description: |
Remove one role from one member. Idempotent — removing a role the
member doesn't have returns 204 no-op.
Same permission + hierarchy constraints as the PUT counterpart.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string } }
- { name: roleId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Role removed (or was already absent — idempotent).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [role_removed] }
guildId: { type: string }
userId: { type: string }
roleId: { type: string }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'403': { description: Discord refused the request (permission or hierarchy issue). }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/messages/{messageId}:
delete:
x-resource-group: "engagement"
operationId: deleteDiscordMessage
tags: [Discord]
summary: Delete a Discord channel message
description: |
Deletes a message from a channel, for moderation and cleanup. This cannot be undone.
Deleting a message the bot did not send requires the bot to hold the Manage Messages
permission, which the Zernio bot requests at install time. Deleting the bot's own
message needs no extra permission.
Ownership is verified by resolving the channel's guild and confirming the caller owns
a Discord account bound to it.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
- { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
responses:
'200':
description: Message deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Invalid accountId, channelId, or messageId format." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: "Discord refused the action (bot lacks Manage Messages)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/messages/{messageId}/crosspost:
post:
x-resource-group: "engagement"
operationId: crosspostDiscordMessage
tags: [Discord]
summary: Crosspost Discord message
description: |
Publishes a message from an announcement channel so it propagates to every server
following that channel.
The source channel must be an announcement channel. Calling this on a regular text
channel returns a 400 before Discord is contacted, because Discord's own error for
this case is opaque.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord announcement channel snowflake ID }
- { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
responses:
'200':
description: Message crossposted.
content:
application/json:
schema:
type: object
properties:
data:
type: object
description: The crossposted Discord message object.
'400': { description: "Invalid ids, or the channel is not an announcement channel." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: Discord refused the action (bot lacks the required permission). }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/threads:
post:
x-resource-group: "engagement"
operationId: createDiscordThread
tags: [Discord]
summary: Create a Discord public thread
description: |
Creates a public thread in a channel. Pass `messageId` to start the thread from an
existing message, or omit it to create a standalone thread.
Threads created here are always public. Requires the bot to hold Create Public
Threads, which the Zernio bot requests at install time.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100, description: Thread name }
messageId:
type: string
description: Optional message snowflake to start the thread from. Omit for a standalone thread.
autoArchiveDuration:
type: integer
enum: [60, 1440, 4320, 10080]
description: Minutes of inactivity before the thread auto-archives. Discord accepts only these four values.
example:
name: "Release 2.4 discussion"
autoArchiveDuration: 1440
responses:
'200':
description: Thread created.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id: { type: string, description: Thread snowflake ID }
name: { type: string }
example:
data: { id: "1180000000000000000", name: "Release 2.4 discussion" }
'400': { description: "Invalid accountId, channelId, messageId, name, or autoArchiveDuration." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: "Discord refused the action (bot lacks Create Public Threads)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/pins:
get:
x-resource-group: "engagement"
operationId: listDiscordPinnedMessages
tags: [Discord]
summary: List pinned messages
description: |
Returns the channel's pinned messages, sorted most-recently-pinned
first. Discord caps a channel at 50 pinned messages and returns the
full list unpaginated.
Bot needs READ_MESSAGE_HISTORY in the channel (granted by default
BOT_PERMISSIONS).
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake. }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of any Discord account in the same guild. }
responses:
'200':
description: Pinned messages.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
description: Raw Discord Message object — see DiscordPlatformData for embed/attachment shapes.
properties:
id: { type: string }
channel_id: { type: string }
content: { type: string }
timestamp: { type: string, format: date-time }
author: { type: object }
attachments: { type: array, items: { type: object } }
embeds: { type: array, items: { type: object } }
'400': { description: Invalid channelId or accountId format. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not accessible. }
'502': { description: Bot lacks access to the channel. }
/v1/discord/channels/{channelId}/pins/{messageId}:
put:
x-resource-group: "engagement"
operationId: pinDiscordMessage
tags: [Discord]
summary: Pin a Discord message
description: |
Pin a specific message in a channel. Path shape mirrors Discord's own
API (`PUT /channels/{cid}/pins/{mid}`).
Idempotent — re-pinning an already-pinned message is a 204 no-op.
Constraints:
- Bot needs MANAGE_MESSAGES in the channel.
- 50-pin cap per channel — hitting it returns 400 (Discord-side).
Caller should unpin one first.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string } }
- { name: messageId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Message pinned (or was already pinned — idempotent).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [message_pinned] }
channelId: { type: string }
messageId: { type: string }
'400': { description: Validation error or pin cap (50) reached. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_MESSAGES in the channel. }
delete:
x-resource-group: "engagement"
operationId: unpinDiscordMessage
tags: [Discord]
summary: Unpin a Discord message
description: |
Unpin a message. Same MANAGE_MESSAGES permission requirement as pin.
Idempotent — unpinning a non-pinned message is a 204 no-op.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string } }
- { name: messageId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Message unpinned (or was not pinned — idempotent).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [message_unpinned] }
channelId: { type: string }
messageId: { type: string }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_MESSAGES in the channel. }
/v1/discord/guilds/{guildId}/events:
get:
x-resource-group: "engagement"
operationId: listDiscordScheduledEvents
tags: [Discord]
summary: List Discord scheduled events
description: |
Return all scheduled events in the guild. Events are distinct from
messages — they appear in the server's Events panel and Discord
auto-notifies interested members ahead of start time.
Pass `withUserCount=true` to include `user_count` (number of members
who RSVP'd) on each event. Useful for surfacing engagement.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: withUserCount, in: query, required: false, schema: { type: boolean }, description: Include user_count on each event. }
responses:
'200':
description: List of scheduled events.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DiscordScheduledEvent'
'400': { description: Invalid params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'502': { description: Bot lacks access to the guild's events. }
post:
x-resource-group: "engagement"
operationId: createDiscordScheduledEvent
tags: [Discord]
summary: Create a Discord scheduled event
description: |
Create a guild scheduled event. Three event types, selected via the
discriminator on `entity.type`:
- `external` — off-platform (Zoom, in-person, livestream). Requires
both `location` and `endsAt`. Most common type for scheduler
integrations.
- `voice` — hosted in a Discord voice channel. Requires `channelId`.
- `stage` — hosted in a Discord stage channel. Requires `channelId`.
Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events
PR) need a re-invite OR a server admin manually granting the
permission — see route header for details.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, startsAt, entity]
properties:
accountId: { type: string }
name: { type: string, minLength: 1, maxLength: 100 }
description: { type: string, maxLength: 1000 }
startsAt: { type: string, format: date-time, description: ISO 8601 start time. Must be in the future. }
entity:
oneOf:
- type: object
required: [type, location, endsAt]
properties:
type: { type: string, enum: [external] }
location: { type: string, minLength: 1, maxLength: 100, description: "Where the event takes place (e.g. \"Zoom link\", \"123 Main St\")" }
endsAt: { type: string, format: date-time }
- type: object
required: [type, channelId]
properties:
type: { type: string, enum: [voice] }
channelId: { type: string, description: Voice channel snowflake. }
endsAt: { type: string, format: date-time }
- type: object
required: [type, channelId]
properties:
type: { type: string, enum: [stage] }
channelId: { type: string, description: Stage channel snowflake. }
endsAt: { type: string, format: date-time }
imageDataUri:
type: string
pattern: "^data:image/(png|jpeg|gif);base64,"
description: Optional cover image as a base64 data URI.
examples:
external:
value:
accountId: "65a1b2c3d4e5f60718293a4b"
name: "Weekly AMA"
description: "Bring your questions about the roadmap."
startsAt: "2026-06-15T18:00:00Z"
entity: { type: external, location: "https://zoom.us/j/123", endsAt: "2026-06-15T19:00:00Z" }
voice:
value:
accountId: "65a1b2c3d4e5f60718293a4b"
name: "Game Night"
startsAt: "2026-06-20T20:00:00Z"
entity: { type: voice, channelId: "1234567890123456789" }
responses:
'200':
description: Event created.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'400': { description: "Validation error (missing required fields for the chosen entity type, malformed snowflake, past startsAt, etc.)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_EVENTS in the guild. }
/v1/discord/guilds/{guildId}/events/{eventId}:
get:
x-resource-group: "engagement"
operationId: getDiscordScheduledEvent
tags: [Discord]
summary: Get a Discord scheduled event
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Event.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Event or Discord account not found. }
patch:
x-resource-group: "engagement"
operationId: updateDiscordScheduledEvent
tags: [Discord]
summary: Update a Discord scheduled event
description: |
Patch any subset of fields. Passing `status: 'cancelled'` is how you
cancel an event — Discord doesn't have a dedicated cancel endpoint,
it's a status transition.
Most status transitions Discord enforces (you can't go SCHEDULED →
COMPLETED directly). The common consumer case is SCHEDULED → CANCELED.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
name: { type: string, minLength: 1, maxLength: 100 }
description: { type: string, maxLength: 1000 }
startsAt: { type: string, format: date-time }
endsAt: { type: string, format: date-time }
location: { type: string, minLength: 1, maxLength: 100, description: For external events. }
status: { type: string, enum: [scheduled, active, completed, cancelled], description: "Status transition. Most common: 'cancelled' to cancel an event." }
imageDataUri: { type: string, pattern: "^data:image/(png|jpeg|gif);base64," }
example:
accountId: "65a1b2c3d4e5f60718293a4b"
status: "cancelled"
responses:
'200':
description: Event updated.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'400': { description: "Validation error, no updatable fields beyond accountId provided, or Discord rejected the update (invalid status transition)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Discord refused the update (bot permissions). }
'404': { description: Event or Discord account not found. }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "engagement"
operationId: deleteDiscordScheduledEvent
tags: [Discord]
summary: Delete a Discord scheduled event
description: |
Hard-delete an event. Use PATCH with `status: 'cancelled'` instead
if you want the event preserved in the guild's history.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Event deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: string, description: The deleted event's snowflake. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Event or Discord account not found. }
'502': { description: Bot lacks MANAGE_EVENTS in the guild. }
/v1/queue/slots:
get:
x-resource-group: "publishing"
operationId: listQueueSlots
tags: [Queue]
summary: List schedules
description: Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID to get queues for
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID to retrieve (optional)
- name: all
in: query
required: false
schema: { type: string, enum: ['true', 'false'] }
description: Set to 'true' to list all queues for the profile
responses:
'200':
description: Queue schedule(s) retrieved
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/QueueSlotsResponse'
- type: object
description: All queues response (when all=true)
properties:
queues:
type: array
items:
$ref: '#/components/schemas/QueueSchedule'
count: { type: integer }
examples:
singleQueue:
summary: Single queue response
value:
exists: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
allQueues:
summary: All queues response (all=true)
value:
queues:
- _id: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
isDefault: true
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "09:00" }]
active: true
- _id: "64f0a1b2c3d4e5f6a7b8c9d2"
name: "Evening Content"
isDefault: false
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "18:00" }]
active: true
count: 2
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
post:
x-resource-group: "publishing"
operationId: createQueueSlot
tags: [Queue]
summary: Create schedule
description: |
Create an additional queue for a profile. The first queue created becomes the default.
Subsequent queues are non-default unless explicitly set.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, timezone, slots]
properties:
profileId: { type: string, description: Profile ID }
name: { type: string, description: "Queue name (e.g., Evening Posts)" }
timezone: { type: string, description: IANA timezone }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Evening Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "18:00"
- dayOfWeek: 3
time: "18:00"
- dayOfWeek: 5
time: "18:00"
active: true
responses:
'201':
description: Queue created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
schedule:
$ref: '#/components/schemas/QueueSchedule'
nextSlots:
type: array
items: { type: string, format: date-time }
'400': { description: Invalid request or validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
put:
x-resource-group: "publishing"
operationId: updateQueueSlot
tags: [Queue]
summary: Update schedule
description: |
Create a new queue or update an existing one. Without queueId, creates/updates the default queue. With queueId, updates a specific queue. With setAsDefault=true, makes this queue the default for the profile.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, timezone, slots]
properties:
profileId: { type: string }
queueId: { type: string, description: Queue ID to update (optional) }
name: { type: string, description: Queue name }
timezone: { type: string }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
setAsDefault: { type: boolean, description: Make this queue the default }
reshuffleExisting:
type: boolean
default: false
description: Whether to reschedule existing queued posts to match new slots
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
setAsDefault: false
responses:
'200':
description: Queue schedule updated
content:
application/json:
schema:
$ref: '#/components/schemas/QueueUpdateResponse'
example:
success: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
reshuffledCount: 0
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
delete:
x-resource-group: "publishing"
operationId: deleteQueueSlot
tags: [Queue]
summary: Delete schedule
description: |
Delete a queue from a profile. Pass queueId to delete a specific queue;
omit it to delete all queues for the profile.
If deleting the default queue, another queue will be promoted to default.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: false
schema: { type: string }
description: Queue ID to delete. Omit to delete all queues for the profile
responses:
'200':
description: Queue schedule deleted
content:
application/json:
schema:
$ref: '#/components/schemas/QueueDeleteResponse'
example:
success: true
deleted: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile or queue not found }
/v1/queue/preview:
get:
x-resource-group: "publishing"
operationId: previewQueue
tags: [Queue]
summary: Preview upcoming slots
description: Returns the next N upcoming queue slot times for a profile as ISO datetime strings.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
schema: { type: string }
description: Filter by specific queue ID. Omit to use the default queue.
- name: count
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: Queue slots preview
content:
application/json:
schema:
$ref: '#/components/schemas/QueuePreviewResponse'
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
count: 10
slots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-04T14:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
- "2024-11-08T10:00:00-05:00"
- "2024-11-11T09:00:00-05:00"
- "2024-11-11T14:00:00-05:00"
- "2024-11-13T09:00:00-05:00"
- "2024-11-15T10:00:00-05:00"
- "2024-11-18T09:00:00-05:00"
- "2024-11-18T14:00:00-05:00"
'400': { description: Invalid parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile or queue schedule not found }
/v1/queue/next-slot:
get:
x-resource-group: "publishing"
operationId: getNextQueueSlot
tags: [Queue]
summary: Get next available slot
description: Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID (optional, defaults to profile's default queue)
responses:
'200':
description: Next available slot
content:
application/json:
schema:
$ref: '#/components/schemas/QueueNextSlotResponse'
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
nextSlot: "2024-11-04T09:00:00-05:00"
timezone: "America/New_York"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
queueName: "Morning Posts"
'400':
description: Invalid parameters or inactive queue
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: "Profile or queue schedule not found, or no available slots"
# ============================================
# Webhooks API (Multi-Webhook System)
# ============================================
/v1/webhooks/settings:
get:
x-resource-group: "webhooks"
operationId: getWebhookSettings
tags: [Webhooks]
summary: List webhooks
description: Retrieve all configured webhooks for the authenticated user. Supports up to 50 webhooks per user.
security:
- bearerAuth: []
responses:
'200':
description: Webhooks retrieved successfully
content:
application/json:
schema:
type: object
properties:
webhooks:
type: array
items:
$ref: '#/components/schemas/Webhook'
example:
webhooks:
- _id: "507f1f77bcf86cd799439011"
name: "My Production Webhook"
url: "https://example.com/webhook"
events: ["post.published", "post.failed"]
isActive: true
lastFiredAt: "2024-01-15T10:30:00Z"
failureCount: 0
- _id: "507f1f77bcf86cd799439012"
name: "Slack Notifications"
url: "https://hooks.slack.com/services/xxx"
events: ["post.failed", "account.disconnected"]
isActive: true
failureCount: 0
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
post:
x-resource-group: "webhooks"
operationId: createWebhookSettings
tags: [Webhooks]
summary: Create webhook
description: |
Create a new webhook configuration. Maximum 50 webhooks per user.
`name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are automatically disabled after 10 consecutive delivery failures.
A restricted (zrk_) API key can only subscribe to events whose resource group
the key holds; an event outside the key's groups is rejected with 403, so a
restricted key can never create a subscription broader than itself.
`disabledResourceGroups` restricts the subscription itself, independently of
which key or session later reads it. Events in a disabled group are dropped
before delivery to this endpoint, on live delivery and on every replay path
(test fire, redelivery, dead-letter requeue), even if they are listed in
`events`. Omit it to receive everything in `events`, which is how existing
subscriptions behave. A restricted key's own disabled groups are always
unioned in.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- url
- events
properties:
name:
type: string
description: Webhook name (1-50 characters)
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed)
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, 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, verification.approved, verification.failed]
description: Events to subscribe to (at least one required)
isActive:
type: boolean
default: true
description: Enable or disable webhook delivery. Defaults to `true` when omitted.
customHeaders:
type: object
additionalProperties:
type: string
description: Custom headers to include in webhook requests
disabledResourceGroups:
type: array
items:
type: string
enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
description: >-
Resource groups this subscription does not receive
(opt-out denylist). Omit or send an empty array to receive
every event in `events`. Listing a group here drops its
events before delivery and on every replay path. Set at
creation it applies to everything this subscription ever
receives; changed later via PUT it applies to events
emitted after the change, with a five-minute tail for
events already queued (see that operation). When the caller
is a restricted (zrk_) key, that key's own disabled groups
are unioned into whatever you send here, so a restricted
key can never create a subscription wider than itself.
examples:
createWebhook:
summary: Create webhook with all events
value:
name: "My Production Webhook"
url: "https://example.com/webhook"
secret: "your-secret-key"
events: ["post.scheduled", "post.published", "post.failed", "post.partial", "post.cancelled", "post.recycled", "account.connected", "account.disconnected", "account.ads.initial_sync_completed", "message.received", "message.sent", "message.edited", "message.deleted", "message.delivered", "message.read", "message.failed", "comment.received", "review.new", "review.updated", "ad.status_changed"]
isActive: true
createWebhookWithoutMessages:
summary: Subscription that does not receive private message payloads
value:
name: "Publishing Webhook"
url: "https://example.com/webhook"
events: ["post.published", "post.failed", "comment.received"]
disabledResourceGroups: ["messages", "contacts"]
isActive: true
responses:
'200':
description: Webhook created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
webhook:
$ref: '#/components/schemas/Webhook'
'400': { description: Validation error or maximum webhooks reached }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
put:
x-resource-group: "webhooks"
operationId: updateWebhookSettings
tags: [Webhooks]
summary: Update webhook
description: |
Update an existing webhook configuration. All fields except `_id` are optional; only provided fields will be updated.
When provided, `name` must be 1-50 characters, `url` must be a valid URL, and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are automatically disabled after 10 consecutive delivery failures.
A restricted (zrk_) API key can only set `events` to events whose resource
group the key holds; an event outside the key's groups is rejected with 403.
It also cannot widen an existing subscription past its own groups.
`disabledResourceGroups` replaces the subscription's own denylist, which
applies to delivery regardless of which key or session created it. Send an
empty array to clear it. A restricted key's own disabled groups are unioned
into the stored value on every update, so repointing a legacy unrestricted
subscription with a restricted key also narrows it.
Timing: the new denylist applies to every event emitted after the update.
Events already queued for delivery when the update landed were filtered
against the previous denylist and can still arrive at your endpoint for up
to five minutes after they were enqueued, because the delivery worker
trusts a five-minute enqueue-time snapshot before re-checking the
subscription. Retries beyond that window, dead-letter replays, test fires,
and redeliveries are all checked against the current denylist.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- _id
properties:
_id:
type: string
description: Webhook ID to update (required)
name:
type: string
description: Webhook name (1-50 characters). Must be non-empty if provided.
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided.
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, 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, 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/test:
post:
x-resource-group: "webhooks"
operationId: testWebhook
tags: [Webhooks]
summary: Send test webhook
description: |
Send a test webhook to verify your endpoint is configured correctly. The test payload includes event: "webhook.test" to distinguish it from real events.
`webhook.test` belongs to the `webhooks` resource group, so a key with that
group disabled is rejected with 403, as is a test fire on a subscription that
lists `webhooks` in its own `disabledResourceGroups` (a 403, not a reported
delivery failure). Replays of real events (redelivery, dead-letter requeue) run
the same checks as live delivery, against both the key's groups and the
subscription's.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- webhookId
properties:
webhookId:
type: string
description: ID of the webhook to test
example:
webhookId: "507f1f77bcf86cd799439011"
responses:
'200':
description: Test webhook sent successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Test webhook sent successfully"
'400': { description: Webhook ID required }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
'404': { description: Webhook not found }
'500':
description: Test webhook failed to deliver
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: false
message: "Test webhook failed"
/v1/logs:
get:
x-resource-group: "publishing"
operationId: listLogs
tags: [Logs]
summary: List activity logs
description: |
Unified logs endpoint. Returns logs for publishing, connections, webhooks, and messaging.
Filter by type, platform, status, and time range. Logs are retained for 90 days.
security:
- bearerAuth: []
parameters:
- name: type
in: query
description: |
Log category to query. Use `all` for the unified view across every category,
or `api_request` for your API request logs (method, path, status, latency).
schema:
type: string
enum: [all, publishing, connections, webhooks, messaging, workflow_event, api_request]
default: publishing
- name: status
in: query
description: Filter by status
schema:
type: string
enum: [success, failed, pending, skipped, all]
- name: platform
in: query
description: Filter by platform
schema:
type: string
enum: [tiktok, instagram, whatsapp, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, all]
- name: action
in: query
description: Filter by action (e.g., post.published, message.sent, account.connected, webhook.delivered)
schema:
type: string
- name: search
in: query
description: Free-text search across log fields
schema:
type: string
- name: days
in: query
description: Number of days to look back (max 90)
schema:
type: integer
minimum: 1
maximum: 90
default: 90
- name: limit
in: query
description: Maximum number of logs to return (max 100)
schema:
type: integer
minimum: 1
maximum: 100
default: 50
- name: skip
in: query
description: Number of logs to skip (for pagination)
schema:
type: integer
minimum: 0
default: 0
- name: account_id
in: query
description: Filter by connected account ID
schema:
type: string
- name: event
in: query
description: Filter webhook logs by event (e.g. post.published, message.received)
schema:
type: string
- name: request_id
in: query
description: Correlation ID — returns every log spawned by a single API request
schema:
type: string
- name: from
in: query
description: Precise start instant (ISO 8601); narrows within the day range
schema:
type: string
format: date-time
- name: to
in: query
description: Precise end instant (ISO 8601)
schema:
type: string
format: date-time
- name: status_code
in: query
description: Filter by exact HTTP status code (api_request logs)
schema:
type: integer
- name: api_key_id
in: query
description: Filter by the API key that made the request (api_request logs)
schema:
type: string
- name: include_read_receipts
in: query
description: Include message.read / message.delivered events (hidden by default for messaging logs)
schema:
type: boolean
default: false
responses:
'200':
description: Logs retrieved successfully
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
type: object
properties:
type:
type: string
description: Log category (publishing, connections, webhooks, messaging)
action:
type: string
description: Specific action (post.published, message.sent, account.connected, etc.)
user_id:
type: string
platform:
type: string
account_id:
type: string
status:
type: string
enum: [success, failed, pending, skipped]
status_code:
type: integer
error_message:
type: string
error_code:
type: string
duration_ms:
type: integer
endpoint:
type: string
description: The API endpoint that triggered this log
request_body:
type: string
description: Request JSON (truncated to 5KB)
response_body:
type: string
description: Response JSON (truncated to 10KB)
created_at:
type: string
format: date-time
metadata:
type: string
description: Additional context as JSON string
request_id:
type: string
description: Correlation ID linking every log from one API request (api_request logs)
api_key_id:
type: string
description: The API key that made the request (api_request logs)
method:
type: string
description: HTTP method (api_request logs)
path:
type: string
description: Request path (api_request logs)
ip_address:
type: string
description: Client IP address (api_request logs)
user_agent:
type: string
description: Client user-agent (api_request logs)
pagination:
type: object
properties:
total:
type: integer
limit:
type: integer
skip:
type: integer
pages:
type: integer
hasMore:
type: boolean
'401': { $ref: '#/components/responses/Unauthorized' }
# Unified Inbox Endpoints
/v1/inbox/conversations:
get:
x-resource-group: "messages"
operationId: listInboxConversations
summary: List conversations
description: |
Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.
Supported platforms: Facebook, Instagram, Twitter/X, Bluesky, Reddit, Telegram.
Twitter/X limitation: X has replaced traditional DMs with encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not accessible via X's API (the /2/dm_events endpoint only returns legacy unencrypted DMs). This means some Twitter/X conversations may show only outgoing messages or appear empty. This is an X platform limitation that affects all third-party applications. See X's docs on encrypted messaging for more details.
Instagram and Facebook pre-connect history: when one of these accounts is connected, Zernio replays the DM history the account already holds on Meta, so conversations that began before the account was connected appear here. Up to 500 conversations per account are replayed. The replay runs in the background and can finish after a listing you have already taken, and replayed conversations keep their original lastMessageAt, so they sort into date order rather than appearing at the top. If you mirror this endpoint into your own store, re-run the sweep rather than relying on a single pass at connect time. Replayed history emits no webhooks and is stored as already read, so it never affects unread counts. Threads that Meta refuses to serve are skipped, and an account whose Instagram "connected tools" message access is turned off is not replayed at all.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: platform
in: query
schema: { type: string, enum: [facebook, instagram, twitter, bluesky, reddit, telegram, whatsapp] }
description: Filter by platform
- name: status
in: query
schema: { type: string, enum: [active, archived] }
description: Filter by conversation status
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order by updated time
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
description: Maximum number of conversations to return
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor for next page
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated conversations
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: 'Opaque conversation identifier. Pass it back verbatim to any /v1/inbox/conversations/{conversationId} route; do not assume a fixed format.'
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
participantId: { type: string }
participantName: { type: string }
participantPicture: { type: [string, "null"] }
participantVerifiedType:
type: [string, "null"]
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X conversations.
lastMessage: { type: string }
updatedTime: { type: string, format: date-time }
status: { type: string, enum: [active, archived] }
unreadCount: { type: [integer, "null"], description: Number of unread messages }
url:
type: [string, "null"]
description: Direct link to open the conversation on the platform (if available)
instagramProfile:
type: [object, "null"]
description: Instagram profile data for the participant. Only present for Instagram conversations.
properties:
isFollower:
type: [boolean, "null"]
description: Whether the participant follows your Instagram business account
isFollowing:
type: [boolean, "null"]
description: Whether your Instagram business account follows the participant
followerCount:
type: [integer, "null"]
description: The participant's follower count on Instagram
isVerified:
type: [boolean, "null"]
description: Whether the participant is a verified Instagram user
fetchedAt:
type: [string, "null"]
format: date-time
description: When this profile data was last fetched from Instagram
metadata:
type: [object, "null"]
description: |
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: |
Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread.
Supported platforms: X/Twitter, Bluesky, Reddit, WhatsApp, SMS, and Slack. Other platforms return PLATFORM_NOT_SUPPORTED.
Slack: pass a workspace member id as participantId (list them with GET /v1/accounts/{accountId}/slack-members). Zernio opens the DM channel with that member and sends the message; the thread then behaves like any other Slack conversation in the inbox. The member must belong to the connected workspace.
WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (variable values for the text header, body and dynamic URL buttons, in that order), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED. Templates with media headers (image, video, document) are handled automatically: Zernio reads the approved template definition and fills the header at send time with the template's approved sample asset. To send a DIFFERENT asset per message (e.g. a distinct invoice PDF for each recipient), pass the headerMedia field with a public link (or a Meta media id); it overrides the sample for that send. 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. Calling this for a number you already have a thread with simply sends the template into that thread, which also makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages). Template fields are accepted on the JSON body only, not on multipart requests. Alternatively, WhatsApp Business Accounts eligible for Meta Direct Send can open a conversation with a business-initiated utility text message and no template: pass category: 'utility' together with message (and no templateName). See the category field below.
DM eligibility (X/Twitter): Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility.
X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials.
Rate limits (X/Twitter only): X's DM API enforces 200 requests per 15 minutes, 1,000 per 24 hours per connected X account, and 15,000 per 24 hours per X developer app (shared across all DM endpoints). These limits do NOT apply to other platforms. WhatsApp sends are governed by Meta's per-number messaging tiers (unique business-initiated conversations per 24 hours) and per-number throughput instead.
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: The social account ID to send from
participantId:
type: string
description: 'Recipient identifier. For X this is the numeric user ID; for WhatsApp and SMS, the recipient phone number in international format (digits, country code included); for Slack, the workspace member id (e.g. U01ABCDEF). Provide either this or participantUsername.'
participantUsername:
type: string
description: Recipient handle/username — an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId.
message:
type: string
description: 'Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required. Required when category is set (a Direct Send utility message is a text message).'
skipDmCheck:
type: boolean
default: false
description: X/Twitter only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs.
templateName:
type: string
description: 'WhatsApp only. Name of the approved template to start the conversation with. Required for WhatsApp unless category is used instead (Direct Send). Cannot be combined with category.'
category:
type: string
enum: [utility]
description: >-
WhatsApp only (Meta Direct Send). Combined with message and without
templateName, starts the conversation with a business-initiated
UTILITY message and no pre-approved template; Meta matches or
auto-creates a template asynchronously. The WhatsApp Business
Account must be eligible for Direct Send, otherwise the send fails
with an error telling you to use an approved message template
instead. Cannot be combined with templateName (templates are
already categorized at creation). Utility messages only; marketing
content is not allowed under this category. Accepted on the JSON
body only, not on multipart requests.
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]. 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).'
headerMedia:
type: object
description: >-
WhatsApp only. Overrides a media-header template's header asset for THIS send,
so a template with an image/video/document header can carry a different asset
per message (e.g. each recipient their own invoice PDF). Without it, the
template's approved sample asset is sent. Provide exactly one of link or id.
required: [type]
properties:
type:
type: string
enum: [image, video, document]
description: Must match the template header's media type.
link:
type: string
description: Public URL of the asset to send. Must be reachable without auth.
id:
type: string
description: A Meta media id (from the media upload endpoint), as an alternative to link.
filename:
type: string
description: 'Document display name shown to the recipient (e.g. "Factura 0001-123.pdf"). document type only; ignored for image/video.'
multipart/form-data:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: The social account ID to send from
participantId:
type: string
description: Twitter numeric user ID of the recipient
participantUsername:
type: string
description: Twitter username (with or without @) of the recipient
message:
type: string
description: Text content of the message
attachment:
type: string
format: binary
description: Media attachment (image or video). One attachment per message.
skipDmCheck:
type: string
enum: ['true', 'false']
default: 'false'
description: Skip the DM eligibility check
responses:
'201':
description: Conversation created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
data:
type: object
properties:
messageId:
type: string
description: Platform message ID (dm_event_id)
conversationId:
type: string
description: Platform conversation ID (dm_conversation_id). For WhatsApp, this is Zernio's internal conversation id (24-character hex) which matches the id returned by the list-conversations endpoint and the conversationId in the message.received and conversation.started webhooks; use it to correlate the created thread with inbound events.
participantId:
type: string
description: Twitter numeric user ID of the recipient
participantName:
type: [string, "null"]
description: Display name of the recipient
participantUsername:
type: [string, "null"]
description: Twitter username of the recipient
'400':
description: 'Validation error, platform not supported, category combined with templateName or used on a non-WhatsApp account, or the WhatsApp Business Account is not eligible for Direct Send'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PLATFORM_NOT_SUPPORTED] }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required or profile limit reached
'404':
description: 'Account or recipient user not found (Reddit: PARTICIPANT_NOT_FOUND when the u/username does not exist)'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [account_not_found, PARTICIPANT_NOT_FOUND] }
'422':
description: 'Recipient does not accept DMs from this account (X/Twitter), or does not accept private messages from you (Reddit)'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [DM_NOT_ALLOWED] }
'429':
description: 'X API rate limit exceeded, or Reddit rate limit reached for this account'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [rate_limited] }
/v1/inbox/conversations/search:
get:
x-resource-group: "messages"
operationId: searchInboxConversations
summary: Search conversations
description: |
Search your conversations two ways at once, and get back the matching conversations, most-recent match first:
- Message text: matches words inside message bodies. Case-insensitive and accent-insensitive, exact tokens only (no substrings, no stemming). Each hit carries up to 3 most-recent matching messages. With direction=outgoing you can collect examples of how you write to customers, for example to teach an AI agent your tone of voice.
- Contact identity: matches the participant's name, username, or phone number as a case-insensitive substring. These hits have matchCount 0 and an empty matches array.
A conversation that matches both ways is returned once, carrying its message matches.
Only platforms whose messages are stored by Zernio are searchable: WhatsApp, SMS, Telegram, Facebook, Instagram, Twitter/X and Reddit. Bluesky conversations are fetched live from the platform and cannot be searched; those accounts are listed in meta.accountsSkipped.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: query
in: query
required: true
schema: { type: string, minLength: 2, maxLength: 200 }
description: "Text to search for, in message content and in the contact's name, username, or phone number"
- name: direction
in: query
schema: { type: string, enum: [incoming, outgoing] }
description: "Only match messages sent to you (incoming) or by you (outgoing). Contact-identity matching is not applied when this is set."
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: platform
in: query
schema: { type: string, enum: [facebook, instagram, telegram, whatsapp, sms, slack] }
description: Filter by platform (searchable platforms only)
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
description: Maximum number of conversations to return
- name: cursor
in: query
schema: { type: string }
description: "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: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
- name: accountId
in: query
required: true
schema: { type: string }
description: The social account ID
responses:
'200':
description: Conversation details
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id: { type: string }
accountId: { type: string }
accountUsername: { type: string }
platform: { type: string }
status: { type: string, enum: [active, archived] }
participantName: { type: string }
participantId: { type: string }
participantVerifiedType:
type: [string, "null"]
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X conversations.
lastMessage: { type: string }
lastMessageAt: { type: string, format: date-time }
updatedTime: { type: string, format: date-time }
participants:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
instagramProfile:
type: [object, "null"]
description: Instagram profile data for the participant. Only present for Instagram conversations.
properties:
isFollower:
type: [boolean, "null"]
description: Whether the participant follows your Instagram business account
isFollowing:
type: [boolean, "null"]
description: Whether your Instagram business account follows the participant
followerCount:
type: [integer, "null"]
description: The participant's follower count on Instagram
isVerified:
type: [boolean, "null"]
description: Whether the participant is a verified Instagram user
fetchedAt:
type: [string, "null"]
format: date-time
description: When this profile data was last fetched from Instagram
metadata:
type: [object, "null"]
description: |
Ad-click attribution for a conversation that started from a Meta ad.
Absent when the conversation did not originate from an ad click.
Captured once, on the first inbound message after the click, and never
overwritten. If the same person later clicks a different ad, the
original values are kept. Meta only sends the referral on that first
message.
This operation currently returns only the `meta_ad_*` family, which
covers Instagram Click-to-Direct and Facebook Messenger
Click-to-Message. WhatsApp Click-to-WhatsApp attribution (the `ctwa_*`
keys, where the ad ID is `ctwa_source_id`) is returned by
`GET /v1/inbox/conversations` instead.
Every key is optional and only the keys Meta supplied are returned, so
read defensively. Meta does not send a campaign or ad set ID, so none is
exposed here. More keys may be added over time. Treat any key you do not
recognise as an opaque string.
Key names differ from the `message.received` webhook on purpose. The
webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)
while the stored conversation record uses the prefixed names below.
Renaming either side would break existing integrations, so both
spellings are kept.
properties:
meta_ad_id:
type: string
description: "The Meta ad ID the user clicked. Always present when a referral was captured."
meta_ad_source:
type: string
description: "Meta-supplied source identifier, for example ADS."
meta_ad_type:
type: string
description: "Meta-supplied referral type, for example OPEN_THREAD."
meta_ad_ref:
type: string
description: "The ref parameter passed through from the ad creative."
meta_ad_title:
type: string
description: "Title of the ad creative at click time."
meta_ad_photo_url:
type: string
description: "Image of the ad creative at click time."
meta_ad_video_url:
type: string
description: "Video of the ad creative at click time."
meta_ad_post_id:
type: string
description: "The organic post the ad promoted, when the ad was a boosted post."
meta_ad_product_id:
type: string
description: "The catalogue product the user clicked, for product ads."
meta_ad_flow_id:
type: string
description: "The Meta flow the ad launched, for flow ads."
meta_ad_captured_at:
type: string
format: date-time
description: "When Zernio stored this referral. Always present when a referral was captured."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Conversation not found
put:
x-resource-group: "messages"
operationId: updateInboxConversation
summary: Update conversation status
description: Archive or activate a conversation. Requires accountId in request body.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, status]
properties:
accountId: { type: string, description: Social account ID }
status: { type: string, enum: [active, archived] }
responses:
'200':
description: Conversation updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
id: { type: string }
accountId: { type: string }
status: { type: string, enum: [active, archived] }
platform: { type: string }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Conversation not found (WhatsApp only; other platforms upsert)
/v1/inbox/conversations/{conversationId}/messages:
get:
x-resource-group: "messages"
operationId: getInboxConversationMessages
summary: List messages
description: |
Fetch messages for a specific conversation, with cursor-based pagination
and ordering control.
Pagination: pass `pagination.nextCursor` from a prior response back as
the `cursor` query param to fetch the next page. The cursor is opaque;
do not parse or construct it client-side.
Sort order: defaults to `asc` (oldest first, chat style). For the
"show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`.
Twitter, Instagram, Telegram, WhatsApp and Reddit honor the requested
order from the local message store. For Facebook and Bluesky, the
upstream APIs only return newest-first and have no order parameter —
sort order is best-effort and only reverses items within a single page
(pages still walk newest→oldest). The response field `sortOrderApplied`
tells you what was actually applied.
Reddit threads are paginated client-side because Reddit's API has no
per-thread cursor. Very long threads may be upstream-truncated by
Reddit's inbox/sent windows (~100 most-recent items each); this is a
Reddit platform limitation.
Instagram and Facebook conversations include history from before the
account was connected, replayed from Meta. That replay covers the 500
most recent messages per conversation: a longer thread keeps its newest
500 and older messages are not retrievable. Messages that arrived after
the account was connected are unaffected. Replayed messages are stored
as already read and emit no webhooks.
Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details.
This endpoint is read-only and does NOT mark messages as read or send
read receipts. To mark a conversation read (and send WhatsApp blue ticks
on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
description: Number of messages to return per page. Default 100, max 100.
- name: cursor
in: query
required: false
schema: { type: string }
description: 'Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response 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).
Twitter, Instagram, Telegram, WhatsApp and Reddit honor this order
across cursor pages. For Facebook and Bluesky, only intra-page
ordering is affected — pages always walk newest→oldest. See
`sortOrderApplied` in the response.
responses:
'200':
description: Messages in conversation
content:
application/json:
schema:
type: object
properties:
status: { type: string }
pagination:
type: object
properties:
hasMore:
type: boolean
description: Whether more messages are available beyond this page.
nextCursor:
type: [string, "null"]
description: Opaque cursor to fetch the next page. `null` on the last page.
sortOrderApplied:
type: string
enum: [asc, desc]
description: |
Sort order actually applied to the returned page. May
differ from the requested `sortOrder` for Facebook and
Bluesky (always `desc` regardless of request).
messages:
type: array
items:
type: object
properties:
id: { type: string }
conversationId: { type: string }
accountId: { type: string }
platform: { type: string }
message: { type: string }
senderId: { type: string }
senderName: { type: [string, "null"] }
senderVerifiedType:
type: [string, "null"]
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X messages.
direction: { type: string, enum: [incoming, outgoing] }
createdAt: { type: string, format: date-time }
attachments:
type: array
items:
type: object
properties:
id: { type: string }
type: { type: string, enum: [image, video, audio, file, sticker, share] }
url:
type: string
description: 'Direct media link. On Instagram and Facebook this is a signed Meta CDN url that EXPIRES: use it now, do not store it. Persist `refreshUrl` instead.'
refreshUrl:
type: [string, "null"]
description: 'Instagram and Facebook only. Endpoint that resolves this attachment to a working url every time, re-minting it from Meta when the stored one has expired. Safe to store and render indefinitely.'
filename: { type: [string, "null"] }
previewUrl: { type: [string, "null"] }
subject: { type: [string, "null"], description: Reddit message subject }
storyReply: { type: [boolean, "null"], description: Instagram story reply }
isStoryMention: { type: [boolean, "null"], description: Instagram story mention }
# ─── Lifecycle state (edits, deletes, delivery) ──────────────
# Populated by webhook events from the platforms that support
# them. See the support matrix in the Webhooks description
# above. Deleted messages retain their original message and
# attachments — the Zernio dashboard hides this content, but
# it is available here for moderation/compliance use cases.
isEdited:
type: boolean
description: True if the sender has edited this message at least once.
editedAt:
type: [string, "null"]
format: date-time
description: When the most recent edit happened.
editCount:
type: integer
description: Total number of edits applied.
editHistory:
type: array
description: Every prior version of the message, oldest first.
items:
$ref: '#/components/schemas/InboxMessageEditHistoryEntry'
isDeleted:
type: boolean
description: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated.
deletedAt:
type: [string, "null"]
format: date-time
deliveryStatus:
type: [string, "null"]
enum: [sent, delivered, read, failed, deleted]
description: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix).
deliveredAt:
type: [string, "null"]
format: date-time
readAt:
type: [string, "null"]
format: date-time
sentAt:
type: [string, "null"]
format: date-time
description: Original send time for outgoing messages (used for Messenger watermark queries).
deliveryError:
type: [object, "null"]
description: Populated when deliveryStatus === "failed".
properties:
code: { type: integer }
title: { type: string }
message: { type: string }
reactions:
type: array
description: Emoji reactions on this message (WhatsApp / Telegram). At most one per party in a 1:1 thread.
items:
type: object
properties:
emoji: { type: string }
fromMe: { type: boolean, description: "true if the connected account reacted, false if the contact did." }
reactedAt: { type: string, format: date-time }
metadata:
type: object
description: |
Platform-specific extras. Free-form, but commonly includes:
`quotedMessageId` (platformMessageId this message replies to),
`waInteractive` (a compact descriptor of WhatsApp interactive
content sent: buttons / list / cta_url / flow / location_request),
and for inbound interactive taps `interactiveType` / `interactiveId`.
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
post:
x-resource-group: "messages"
operationId: sendInboxMessage
summary: Send message
description: |
Send a message in a conversation. Supports text, attachments, quick replies,
buttons, templates, and message tags. Attachment and interactive message
support varies by platform.
WhatsApp template messages: to send an approved template into this
conversation (required when the 24-hour customer-service window is
closed), use the `template` field with a single element carrying the
template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`.
See the `template` field below for the exact shape. To send a template
to a phone number you have no conversation with yet, use the
create-conversation endpoint (POST /v1/inbox/conversations) instead.
WhatsApp rich interactive messages (list, CTA URL, Flow, location request)
are available via the `interactive` field. Tap events are delivered through
the `message.received` webhook with WhatsApp-specific `metadata` fields
(`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`).
**Idempotency:** send an `Idempotency-Key` header to make retries safe
(e.g. after a client-side timeout where delivery is unknown): same key +
same body replays the original response (with `Idempotent-Replayed: true`)
instead of sending the message a second time; same key + different body
returns 422; a key still in flight returns 409. Works for JSON and
multipart (file upload) requests alike. Keys are retained for 24 hours.
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: Social account ID }
message: { type: string, description: Message text }
attachmentUrl: { type: string, description: "URL of the attachment to send (image, video, audio, or file). The URL must be publicly accessible. For binary file uploads, use multipart/form-data instead. 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.
WhatsApp: sends an approved WhatsApp template message, the only message
type WhatsApp accepts when the 24-hour customer-service window is closed.
Provide exactly one element carrying the template reference:
`{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }`
(`type` is ignored on WhatsApp). `components` is optional and is forwarded
unchanged as the `template.components` array of Meta's Cloud API send
payload; use it to fill body/header variables and button parameters, e.g.
`[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`.
Templates with media headers (image, video, document) must include the
header component with its media link here at send time. To send a template
to a phone number with no existing conversation, or to have media headers
filled in automatically from the template definition, use the
create-conversation endpoint (POST /v1/inbox/conversations) instead.
properties:
type: { type: string, enum: [generic], description: Template type. Required for Instagram/Facebook generic templates; ignored on WhatsApp. }
elements:
type: array
maxItems: 10
items:
type: object
properties:
title: { type: string, maxLength: 80, description: Element title (max 80 chars). Required for Instagram/Facebook generic templates. }
subtitle: { type: string, description: Element subtitle (Instagram/Facebook only) }
imageUrl: { type: string, description: Element image URL (Instagram/Facebook only) }
buttons:
type: array
maxItems: 3
description: Element buttons (Instagram/Facebook only)
items:
type: object
properties:
type: { type: string, enum: [url, postback] }
title: { type: string, maxLength: 20 }
url: { type: string }
payload: { type: string }
name: { type: string, description: WhatsApp only. Name of the approved template to send. }
language: { type: string, description: WhatsApp only. Template language code (e.g. en_US). }
components:
type: array
description: WhatsApp only. Meta Cloud API send-shape components array, forwarded to Meta verbatim.
items:
type: object
additionalProperties: true
interactive:
type: object
description: |
WhatsApp-only. Rich interactive payload for list messages, CTA URL
buttons, Flow prompts, location requests, voice-call buttons, and
commerce messages (single product, product list, catalog, and
carousel). When set, takes priority over `buttons` and
`quickReplies`. The shape mirrors Meta's Cloud API `interactive`
object 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 (both available in message.platformMessageId from webhooks or the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Silently ignored on platforms without send-side reply support, including Instagram and Facebook Messenger (Meta''s Send API rejects reply_to on Instagram and does not expose it on Messenger).'
location:
type: object
description: WhatsApp-only. Send a location pin.
required: [latitude, longitude]
properties:
latitude: { type: number, description: Latitude in decimal degrees. }
longitude: { type: number, description: Longitude in decimal degrees. }
name: { type: string, description: Optional location name. }
address: { type: string, description: Optional street address. }
contacts:
type: array
description: WhatsApp-only. Send one or more contact cards.
items:
type: object
required: [name]
properties:
name:
type: object
required: [formatted_name]
properties:
formatted_name: { type: string, description: Full display name. }
first_name: { type: string }
last_name: { type: string }
phones:
type: array
items:
type: object
properties:
phone: { type: string }
type: { type: string, description: "e.g. CELL, WORK, HOME." }
emails:
type: array
items:
type: object
properties:
email: { type: string }
type: { type: string }
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: Social account ID }
message: { type: string, description: Message text (optional when sending attachment) }
attachment:
type: string
format: binary
description: "File attachment (images, videos, documents). Supported formats: JPEG, PNG, GIF, MP4, AAC, WAV. Max 25MB. 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 (both available in message.platformMessageId from webhooks or the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Silently ignored on platforms without send-side reply support, including Instagram and Facebook Messenger (Meta''s Send API rejects reply_to on Instagram and does not expose it on Messenger).'
voiceNote:
type: string
enum: ['true']
description: WhatsApp-only. Set to "true" when the audio attachment is an in-browser voice recording; the server transcodes it to a WhatsApp-native container (ogg/Opus). Omit for regular audio file uploads.
responses:
'200':
description: Message sent
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
messageId:
type: string
description: '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 (Twitter), 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 }
'400':
description: 'Bad request (e.g., attachment not supported for platform, validation error, category combined with a template or attachment, category used on a non-WhatsApp account, or the WhatsApp Business Account is not eligible for Direct Send)'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code:
type: string
description: "Stable machine-readable reason. PLATFORM_LIMITATION covers a capability the platform does not offer (e.g. Bluesky and Reddit DMs reject media); MISSING_PARTICIPANT means the stored conversation has no recipient to send to."
enum: [PLATFORM_LIMITATION, MISSING_PARTICIPANT]
platformError:
type: object
description: "Instagram/Facebook only. Meta's own diagnostic fields for the rejected send, passed through verbatim so you can tell failure classes apart and quote them to Meta. Absent when the failure did not come from Meta."
properties:
code: { type: integer, description: "Meta error code" }
subcode: { type: integer, description: "Meta error_subcode" }
fbtraceId: { type: string, description: "Meta fbtrace_id, quote this in a Meta bug report" }
type: { type: string, description: "Meta error type (e.g. OAuthException)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'409': { $ref: '#/components/responses/IdempotencyKeyInFlight' }
'422': { $ref: '#/components/responses/IdempotencyKeyReused' }
/v1/whatsapp/media/{mediaId}:
get:
x-resource-group: "messages"
operationId: getWhatsAppMedia
tags: [WhatsApp]
summary: Download WhatsApp media
description: |
Streams the binary for a WhatsApp attachment. This is the endpoint the
`url` on a WhatsApp `attachments[]` entry points at, in both the
`message.received` webhook and the List messages response.
**This is an authenticated endpoint, not a public link.** Send
`Authorization: Bearer <your API key>` exactly as you would for any other
call. Passing the URL straight to a browser, an LLM vision API, or a
no-code "download file" step without the header returns `401`. This is
the most common integration mistake on this endpoint, and it differs from
Instagram, Facebook and Telegram, whose `attachments[].url` is a direct
CDN link that needs no header.
**Fetch on receipt, not lazily.** WhatsApp media lives in Meta's media
store, not ours, and it is removed after a limited retention window
(currently 7 days, and Meta has been dropping some inbound media sooner).
Once Meta drops it the media is unrecoverable and this endpoint answers
`400` permanently, so retrying will never succeed. Download and store the
bytes when the webhook arrives.
security:
- bearerAuth: []
parameters:
- { name: mediaId, in: path, required: true, schema: { type: string }, description: 'The media id from `attachments[].payload.id`.' }
- { name: accountId, in: query, required: true, schema: { type: string }, description: 'The WhatsApp account that received the media.' }
responses:
'200':
description: The media binary, streamed with its original content type.
content:
application/octet-stream:
schema: { type: string, format: binary }
'400':
description: 'Media is no longer available on WhatsApp servers (expired or deleted by Meta). Permanent, do not retry.'
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: 'Account not found, not accessible to the caller, or the media does not belong to it.'
'502':
description: Meta could not be reached or returned an unexpected error.
/v1/inbox/conversations/{conversationId}/messages/{messageId}:
patch:
x-resource-group: "messages"
operationId: editInboxMessage
summary: Edit message
description: |
Edit the text and/or reply markup of a previously sent Telegram message.
Only supported for Telegram. Returns 400 for other platforms.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The Telegram message ID to edit
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
text: { type: string, description: New message text }
replyMarkup:
type: object
description: New inline keyboard markup
properties:
type: { type: string, enum: [inline_keyboard] }
keyboard:
type: array
items:
type: array
items:
type: object
properties:
text: { type: string }
callbackData: { type: string }
url: { type: string }
responses:
'200':
description: Message edited
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
messageId: { type: integer }
'400':
description: Not supported or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
x-resource-group: "messages"
operationId: deleteInboxMessage
summary: Delete message
description: |
Delete a message from a conversation. Platform support varies:
- Telegram: Full delete (bot's own messages anytime, others if admin)
- X/Twitter: Full delete (own DM events only)
- Bluesky: Delete for self only (recipient still sees it)
- Reddit: Delete from sender's view only
- Facebook, Instagram, WhatsApp: Not supported (returns 400)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The platform message ID to delete
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
responses:
'200':
description: Message deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400':
description: Platform does not support deletion or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/typing:
post:
x-resource-group: "messages"
operationId: sendTypingIndicator
summary: Send typing indicator
description: |
Show a typing indicator in a conversation. Platform support:
- Facebook Messenger: Shows "Page is typing..." for 20 seconds
- Instagram: Shows "typing..." to the recipient (works for both Instagram Login and Facebook Login accounts). The recipient must be signed in to Instagram to see it.
- Telegram: Shows "Bot is typing..." for 5 seconds
- WhatsApp: Shows "typing..." for up to 25 seconds. Requires a recent inbound message in the conversation (Meta references the inbound message id) and also marks that message as read as a side-effect.
- All others: Returns 200 but no-op (platform doesn't support it)
Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails; `success` reports whether a typing indicator was actually sent to the platform (`false` on unsupported platforms or when the platform call failed).
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
responses:
'200':
description: Typing indicator sent (or no-op on unsupported platforms)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/read:
post:
x-resource-group: "messages"
operationId: markConversationRead
summary: Mark a conversation as read
description: |
Marks all unread incoming messages in the conversation as read.
For WhatsApp, this also sends read receipts (blue ticks) to the contact,
EXCEPT on coexistence accounts (where the WhatsApp Business app on the
customer's phone owns read state and we never override it).
This is the explicit, human-driven counterpart to `GET .../messages`,
which is side-effect-free and does NOT mark anything read. Call this when
a user actually views the conversation.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
responses:
'200':
description: Conversation marked read
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
markedCount:
type: integer
description: Number of messages marked read by this call
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions:
post:
x-resource-group: "messages"
operationId: addMessageReaction
summary: Add reaction
description: |
Add an emoji reaction to a message. Platform support:
- Telegram: Supports a subset of Unicode emoji reactions
- WhatsApp: Supports any standard emoji (one reaction per message per sender)
- Instagram and Facebook Messenger: Any standard emoji, subject to Meta's 24h messaging window
- Slack: The emoji must have a Slack name (e.g. `:thumbsup:`); unnamed characters return 400
- All others: Returns 400 (not supported)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: 'The platform message ID (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: Social 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: Social 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 uses standard Bearer token authentication for programmatic use.
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The file to upload (max 25MB)
contentType:
type: string
description: 'Override MIME type (e.g. "image/jpeg"). Auto-detected from file if not provided.'
responses:
'200':
description: File uploaded successfully
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Publicly accessible URL for the uploaded file }
filename: { type: string, description: Generated unique filename }
contentType: { type: string, description: MIME type of the file }
size: { type: integer, description: File size in bytes }
'400':
description: No file provided or file too large
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/messenger-menu:
get:
x-resource-group: "accounts"
operationId: getMessengerMenu
summary: Get FB persistent menu
description: Get the persistent menu configuration for a Facebook Messenger account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Persistent menu configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setMessengerMenu
summary: Set FB persistent menu
description: Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [persistent_menu]
properties:
persistent_menu:
type: array
description: Persistent menu configuration array (Meta format)
items: { type: object }
responses:
'200':
description: Menu set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteMessengerMenu
description: Removes the persistent menu from Facebook Messenger conversations for this account.
summary: Delete FB persistent menu
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Menu deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/instagram-ice-breakers:
get:
x-resource-group: "accounts"
operationId: getInstagramIceBreakers
summary: Get IG ice breakers
description: Get the ice breaker configuration for an Instagram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breaker configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not an Instagram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setInstagramIceBreakers
summary: Set IG ice breakers
description: Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ice_breakers]
properties:
ice_breakers:
type: array
minItems: 1
maxItems: 4
items:
type: object
required: [question, payload]
properties:
question: { type: string, minLength: 1, maxLength: 80 }
payload: { type: string, minLength: 1 }
responses:
'200':
description: Ice breakers set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteInstagramIceBreakers
description: Removes the ice breaker questions from an Instagram account's Messenger experience.
summary: Delete IG ice breakers
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breakers deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/telegram-commands:
get:
x-resource-group: "accounts"
operationId: getTelegramCommands
summary: Get TG bot commands
description: Get the bot commands configuration for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Bot commands list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
command: { type: string, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$' }
description: { type: string, minLength: 1, maxLength: 256 }
'400':
description: Not a Telegram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setTelegramCommands
summary: Set TG bot commands
description: Set bot commands for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [commands]
properties:
commands:
type: array
maxItems: 100
items:
type: object
required: [command, description]
properties:
command: { type: string, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$', description: Bot command without leading slash }
description: { type: string, minLength: 1, maxLength: 256, description: Command description }
responses:
'200':
description: Commands set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteTelegramCommands
description: Clears all bot commands configured for a Telegram bot account.
summary: Delete TG bot commands
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Commands deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}:
get:
x-resource-group: "messages"
operationId: getMessageAttachment
summary: Resolve message attachment
description: |
Resolve one attachment on a message to a media url that works right now.
Instagram and Facebook sign DM media urls per request and expire them, so
the `url` on a message is a snapshot: it works when you read the message
and stops working later. This endpoint checks the stored url and, when it
has gone stale, re-mints the message's media from Meta and persists it
before answering. The message id never expires, so this URL is the one to
store — it is returned on each attachment as `refreshUrl`.
By default it responds `302` to the live media url, so it can be used
directly as an `<img src>` on a browser session. API-key integrators
should pass `?format=json` and read `url` off the body, since a browser
cannot attach an Authorization header to an image request.
Only Instagram and Facebook media can be re-minted. On other platforms
the stored url is returned as-is when it still resolves, and `404`
otherwise.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: 'The conversation ID (Zernio id or platform conversation id)'
- name: messageId
in: path
required: true
schema: { type: string }
description: 'The message id as returned by the list-messages endpoint (the platform message id)'
- name: index
in: path
required: true
schema: { type: integer, minimum: 0 }
description: 'Zero-based position of the attachment in the message''s attachments array'
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
- name: format
in: query
required: false
schema: { type: string, enum: [redirect, json], default: redirect }
description: '`redirect` (default) answers 302 to the media; `json` returns the url in the body'
responses:
'200':
description: 'Resolved url (only when format=json)'
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
url: { type: string, description: 'Live media url. Short-lived; re-request this endpoint rather than storing it.' }
refreshed: { type: boolean, description: True when the stored url had expired and was re-minted from the platform. }
'302':
description: Redirect to the live media url
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: 'Account, conversation, message or attachment not found, or the platform no longer serves the media'
/v1/inbox/comments:
get:
x-resource-group: "engagement"
operationId: listInboxComments
summary: List commented posts
description: |
Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts.
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 (Metronome plans always qualify), the user's Meta ads
(boosted/dark posts) are included too. There's one row per (ad, placement-with-comments):
an ad that runs on both Facebook feed and Instagram feed produces up to two rows (the
Page dark post and the IG media have separate comment threads), each flagged
`isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use
`?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns
*organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread
from GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with
the Marketing API token (Facebook side) or the connected Instagram account's token
(Instagram side); a row whose count can't be read is omitted.
Pagination walks each account's platform listing. Following `nextCursor` reaches past
the first page on Facebook, 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, metaads] }
description: "Filter by platform. `metaads` is a synthetic value meaning the user's ads (boosted/dark posts) only; `facebook`/`instagram` return organic posts only."
- name: minComments
in: query
schema: { type: integer, minimum: 0 }
description: Minimum comment count
- name: since
in: query
schema: { type: string, format: date-time }
description: Posts created after this date
- name: sortBy
in: query
schema: { type: string, enum: [date, comments], default: date }
description: Sort field
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
- name: cursor
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated posts with comments
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string }
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
content: { type: string, description: "The post text/caption. On ad rows (isAd: true) this is the AD NAME, not the underlying post's caption — the creative text isn't exposed here." }
picture: { type: [string, "null"], description: 'Post media thumbnail. On ad rows this is the ad creative thumbnail.' }
permalink: { type: [string, "null"], description: 'Public URL of the post. On ad rows: the Facebook dark-post URL (facebook placement) or the IG media permalink (instagram placement); may be null when unknown.' }
createdTime: { type: string, format: date-time }
commentCount: { type: integer }
likeCount: { type: integer, description: 'Not fetched for ad rows (always 0 there).' }
cid: { type: [string, "null"], description: Bluesky content identifier }
subreddit: { type: [string, "null"], description: Reddit subreddit name }
isAd: { type: boolean, description: "True when this row is an ad (boosted/dark post). `platform` is then the placement (facebook = the Page dark post / instagram = the IG media), `id` is `{adId}:{placement}`, and the thread is at GET /v1/ads/{adId}/comments?placement={placement}." }
adId: { type: string, description: "Internal Zernio ad id — only on ad rows." }
placement: { type: string, enum: [facebook, instagram], description: "Which side of the ad this row's comments are on — only on ad rows." }
pagination:
type: object
properties:
hasMore: { type: boolean }
nextCursor: { type: [string, "null"] }
meta:
type: object
properties:
accountsQueried: { type: integer }
accountsFailed: { type: integer }
failedAccounts:
type: array
items:
type: object
properties:
accountId: { type: string }
accountUsername: { type: [string, "null"] }
platform: { type: string }
error: { type: string }
code: { type: [string, "null"], description: Error code if available }
retryAfter: { type: [integer, "null"], description: Seconds to wait before retry (rate limits) }
lastUpdated: { type: string, format: date-time }
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.
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 only) Get replies to a specific comment
responses:
'200':
description: Comments for the post
content:
application/json:
schema:
type: object
properties:
status: { type: string }
comments:
type: array
items:
type: object
properties:
id: { type: string }
message: { type: string }
createdTime: { type: string, format: date-time }
from:
type: object
properties:
id: { type: string }
name: { type: string }
username: { type: string }
picture: { type: [string, "null"] }
isOwner: { type: boolean }
verifiedType:
type: [string, "null"]
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X comments.
likeCount: { type: integer }
replyCount: { type: integer, 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, Twitter/X, Bluesky, Reddit)" }
isHidden: { type: boolean, description: Whether the comment is currently hidden }
isLiked: { type: boolean, description: Whether the current user has liked this comment }
likeUri: { type: [string, "null"], description: Bluesky like URI for unliking }
cid: { type: [string, "null"], description: Bluesky content identifier }
parentId: { type: [string, "null"], description: 'ID of the parent comment. Present on entries inside replies[] for Facebook, Instagram and X/Twitter. On X/Twitter 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 — response includes `adId` and `adCommentsUrl`).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: 'Inbox addon required, or the connected account is not permitted to read this post on the platform (code platform_api_error, type platform_error)'
'429': { $ref: '#/components/responses/RateLimited' }
'502':
description: 'Upstream platform error (code platform_api_error, type platform_error)'
post:
x-resource-group: "engagement"
operationId: replyToInboxPost
summary: Reply to comment
description: Post a reply to a post or specific comment. Requires accountId in request body.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, message]
properties:
accountId: { type: string }
message: { type: string }
attachmentUrl:
type: string
format: uri
description: '(Facebook only) URL of an image to attach, publishing a photo comment alongside the text. The URL must be publicly accessible so Meta can fetch it. Returns 400 for other platforms.'
commentId: { type: string, description: Reply to specific comment (optional) }
parentCid: { type: string, description: (Bluesky only) Parent content identifier }
rootUri: { type: string, description: (Bluesky only) Root post URI }
rootCid: { type: string, description: (Bluesky only) Root post CID }
responses:
'200':
description: Reply posted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
commentId: { type: string }
isReply: { type: boolean }
cid: { type: [string, "null"], description: Bluesky CID }
'400':
description: 'Invalid request (e.g. attachmentUrl on a platform other than Facebook, code PLATFORM_NOT_SUPPORTED)'
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: 'Inbox addon required, or the connected account is not permitted to comment on this post on the platform (code platform_api_error, type platform_error)'
'429': { $ref: '#/components/responses/RateLimited' }
'502':
description: 'Upstream platform error (code platform_api_error, type platform_error)'
delete:
x-resource-group: "engagement"
operationId: deleteInboxComment
summary: Delete comment
description: |
Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn.
Requires accountId and commentId query parameters.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
- name: commentId
in: query
required: true
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 social account ID }
platform: { type: string, enum: [reddit], description: Only Reddit supports editing a comment }
content: { type: string, minLength: 1, description: The new comment body }
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "reddit"
content: "Edited: corrected the version number."
responses:
'200':
description: Comment edited
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
platform: { type: string }
example:
status: "success"
commentId: "t1_xyz789"
platform: "reddit"
'400':
description: "Platform does not support editing comments (code: platform_not_supported), or content missing."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404': { description: Account not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/inbox/comments/{postId}/{commentId}/moderation:
post:
x-resource-group: "engagement"
operationId: setCommentModeration
summary: Set comment moderation status
description: |
Set a comment's moderation status. Supported on YouTube only.
Use this to work a moderation queue: approve a held comment (`published`), reject it
(`rejected`), or send it back for review (`heldForReview`).
The request must be authorized by the owner of the channel or video the comment
belongs to. You cannot moderate comments on videos you do not own.
This is distinct from `POST /v1/inbox/comments/{postId}/{commentId}/hide`, which
covers Facebook, Instagram, Threads, and X/Twitter and does not apply to YouTube.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, platform, moderationStatus]
properties:
accountId: { type: string, description: The social account ID }
platform: { type: string, enum: [youtube], description: Only YouTube supports comment moderation }
moderationStatus:
type: string
enum: [published, rejected, heldForReview]
description: "published approves the comment, rejected removes it, heldForReview returns it to the queue."
banAuthor:
type: boolean
description: >
Also ban the comment's author, auto-rejecting their future comments.
Only valid when moderationStatus is "rejected"; any other pairing is a 400.
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "youtube"
moderationStatus: "rejected"
banAuthor: true
responses:
'200':
description: Moderation status applied
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400':
description: "Platform does not support comment moderation (code: platform_not_supported), or banAuthor was set without moderationStatus=rejected."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404': { description: Account not found }
'502': { description: "YouTube rejected the request (e.g. the account does not own the video)." }
/v1/inbox/comments/{postId}/{commentId}/hide:
post:
x-resource-group: "engagement"
operationId: hideInboxComment
summary: Hide comment
description: |
Hide a comment on a post. Supported by Facebook, Instagram, Threads, and X/Twitter.
Hidden comments are only visible to the commenter and page admin.
For X/Twitter, the reply must belong to a conversation started by the authenticated user.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: The social account ID }
responses:
'200':
description: Comment hidden
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
hidden: { type: boolean }
platform: { type: string }
'400':
description: Platform does not support hiding comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
x-resource-group: "engagement"
operationId: unhideInboxComment
summary: Unhide comment
description: |
Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
responses:
'200':
description: Comment unhidden
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
hidden: { type: boolean }
platform: { type: string }
'400':
description: Platform does not support unhiding comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/comments/{postId}/{commentId}/like:
post:
x-resource-group: "engagement"
operationId: likeInboxComment
summary: Like comment
description: |
Like or upvote a comment on a post. Supported platforms: Facebook, Twitter/X,
Bluesky, Reddit, LinkedIn, 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 social account ID }
reactionType:
type: string
enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
description: '(LinkedIn only) Reaction to create. Defaults to LIKE; ignored on other platforms.'
cid: { type: string, description: (Bluesky only) Content identifier for the comment }
responses:
'200':
description: Comment liked
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
liked: { type: boolean }
likeUri: { type: string, description: (Bluesky only) URI to use for unliking }
platform: { type: string }
'400':
description: Platform does not support liking comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required, or the account is missing the platform permission
delete:
x-resource-group: "engagement"
operationId: unlikeInboxComment
summary: Unlike comment
description: |
Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky,
Reddit, LinkedIn, 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,
Twitter/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 social account acting as the liker }
reactionType:
type: string
enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
description: '(LinkedIn only) Reaction to create. Defaults to LIKE; ignored on other platforms.'
cid: { type: string, description: (Bluesky only) Content identifier of the post }
responses:
'200':
description: Post liked
content:
application/json:
schema:
type: object
properties:
status: { type: string }
postId: { type: string, description: The resolved native post ID }
platform: { type: string }
liked: { type: boolean }
likeUri: { type: string, description: (Bluesky only) URI to use for unliking }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required, or the account is missing the platform scope
'404':
description: Account or post not found
delete:
operationId: unlikePost
summary: Unlike post
description: |
Remove this account's like from a post. Supported platforms: LinkedIn, Twitter/X,
Facebook, YouTube, Bluesky, 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 private message to the author of a comment. Supported on Instagram and Facebook only.
One reply per comment, must be sent within 7 days. Optionally attach interactive elements:
`quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url
buttons rendered in the same bubble via Meta's button_template). Buttons are recommended
for cold reach since chips do not render in the Instagram Message Requests folder.
`quickReplies` and `buttons` are mutually exclusive.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
description: The media/post ID (Instagram media ID or Facebook post ID)
- name: commentId
in: path
required: true
schema: { type: string }
description: The comment ID to send a private reply to
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, message]
properties:
accountId:
type: string
description: The social account ID (Instagram or Facebook)
message:
type: string
description: The message text to send as a private DM
quickReplies:
type: array
description: |
Optional quick-reply chips appended to the message. Visible only in the
Instagram and Messenger apps (not on web). Maximum 13 entries. Mutually
exclusive with `buttons`. Note: chips do NOT render in the Instagram
Message Requests folder where DMs from non-followers land — use `buttons`
instead for cold reach.
maxItems: 13
items:
type: object
required: [title, payload]
properties:
title:
type: string
maxLength: 20
description: Label shown on the chip. Truncated by Meta beyond 20 characters.
payload:
type: string
description: Opaque value returned in the inbound webhook when the user taps the chip.
imageUrl:
type: string
format: uri
description: Optional thumbnail shown next to the chip title.
buttons:
type: array
description: |
Optional 1-3 inline buttons rendered as part of the same message bubble
via Meta's button_template. Visible in the Instagram Message Requests
folder (unlike quick replies). Mutually exclusive with `quickReplies`.
minItems: 1
maxItems: 3
items:
oneOf:
- type: object
required: [type, title, url]
properties:
type: { type: string, enum: [url] }
title: { type: string, maxLength: 20, description: Label shown on the button. }
url: { type: string, format: uri, description: URL opened when the button is tapped. }
- type: object
required: [type, title, payload]
properties:
type: { type: string, enum: [postback] }
title: { type: string, maxLength: 20, description: Label shown on the button. }
payload: { type: string, description: Opaque value returned in the inbound webhook when the user taps the button. }
- type: object
required: [type, title, phone]
properties:
type: { type: string, enum: [phone] }
title: { type: string, maxLength: 20, description: Label shown on the button. Facebook only. }
phone: { type: string, description: 'E.164 phone number dialed when tapped. Facebook only.' }
examples:
textOnly:
summary: Plain text reply
value:
accountId: "507f1f77bcf86cd799439011"
message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question."
withQuickReplies:
summary: Comment-to-DM with quick replies
value:
accountId: "507f1f77bcf86cd799439011"
message: "Thanks for commenting! Want me to send the link?"
quickReplies:
- title: "Yes, send it"
payload: "SEND_LINK"
- title: "No thanks"
payload: "DECLINE"
withButtons:
summary: Comment-to-DM with inline buttons (recommended for cold reach)
value:
accountId: "507f1f77bcf86cd799439011"
message: "Thanks for commenting! Tap below to grab the link."
buttons:
- type: "url"
title: "Get the link"
url: "https://tribenest.co/landing"
- type: "postback"
title: "Remind me later"
payload: "REMIND_LATER"
responses:
'200':
description: Private reply sent successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
messageId:
type: string
description: The ID of the sent message
commentId:
type: string
description: The comment ID that was replied to
platform:
type: string
enum: [instagram, facebook]
example: instagram
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: string
code:
type: string
enum: [PLATFORM_LIMITATION]
examples:
platformNotSupported:
summary: Platform not supported
value:
error: "Private replies to comments are only supported on Instagram and Facebook."
code: "PLATFORM_LIMITATION"
alreadyReplied:
summary: Already sent a private reply
value:
error: "A private reply has already been sent to this comment, or the 7-day reply window has expired. Only one private reply per comment is allowed within 7 days."
commentTooOld:
summary: Comment older than 7 days
value:
error: "The comment is older than 7 days. Private replies can only be sent within 7 days of the comment being posted."
missingMessage:
summary: Missing message
value:
error: "message is required and must be a non-empty string"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account not found
/v1/twitter/retweet:
post:
x-resource-group: "engagement"
operationId: retweetPost
summary: Retweet a post
description: |
Retweet (repost) a tweet by ID.
Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The social account ID }
tweetId: { type: string, description: The ID of the tweet to retweet }
responses:
'200':
description: Tweet retweeted
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
delete:
x-resource-group: "engagement"
operationId: undoRetweet
summary: Undo retweet
description: |
Undo a retweet (un-repost a tweet).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the original tweet to un-retweet
responses:
'200':
description: Retweet undone
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
/v1/twitter/bookmark:
post:
x-resource-group: "engagement"
operationId: bookmarkPost
summary: Bookmark a tweet
description: |
Bookmark a tweet by ID.
Requires the bookmark.write OAuth scope.
Rate limit: 50 requests per 15-min window.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The social account ID }
tweetId: { type: string, description: The ID of the tweet to bookmark }
responses:
'200':
description: Tweet bookmarked
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
delete:
x-resource-group: "engagement"
operationId: removeBookmark
summary: Remove bookmark
description: |
Remove a bookmark from a tweet.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the tweet to unbookmark
responses:
'200':
description: Bookmark removed
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
/v1/twitter/follow:
post:
x-resource-group: "engagement"
operationId: followUser
summary: Follow a user
description: |
Follow a user on X/Twitter.
Requires the follows.write OAuth scope.
For protected accounts, a follow request is sent instead (pending_follow will be true).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, targetUserId]
properties:
accountId: { type: string, description: The social account ID }
targetUserId: { type: string, description: The Twitter ID of the user to follow }
responses:
'200':
description: User followed or follow request sent
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
targetUserId: { type: string }
following: { type: boolean }
pending_follow: { type: boolean, description: True if the target account is protected and a follow request was sent }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
delete:
x-resource-group: "engagement"
operationId: unfollowUser
summary: Unfollow a user
description: |
Unfollow a user on X/Twitter.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: targetUserId
in: query
required: true
schema: { type: string }
description: The Twitter ID of the user to unfollow
responses:
'200':
description: User unfollowed
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
targetUserId: { type: string }
following: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
/v1/twitter/search:
get:
x-resource-group: "engagement"
operationId: searchTweets
summary: Search recent tweets
description: |
Search public tweets from the last 7 days matching an X search query, e.g. to discover tweets to reply to.
The query string is passed through to X unchanged and supports X's search operators
(`from:user`, `-is:retweet`, `is:reply`, `lang:en`, `"exact phrase"`, `conversation_id:123`, boolean `OR`, ...).
Note that standalone operators like `is:` / `has:` / `lang:` must be combined with a keyword or `from:` clause.
To reply to a found tweet, pass its `id` as the twitter platform entry's `platformSpecificData.replyToTweetId` when creating a post.
Rate limit: 300 requests per 15-min window per connected account.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The social account ID
- name: query
in: query
required: true
schema: { type: string, minLength: 1, maxLength: 512 }
description: 'X search query, max 512 characters. Operators are passed through unchanged; X rejects malformed queries with a 400.'
- name: limit
in: query
schema: { type: integer, minimum: 10, maximum: 100, default: 10 }
description: 'Results per page. X requires a minimum of 10; values below 10 are rejected.'
- name: sinceId
in: query
schema: { type: string, pattern: '^[0-9]{1,19}$' }
description: 'Only return tweets with an ID greater than (more recent than) this numeric tweet ID. Non-numeric values are rejected with 400.'
- name: untilId
in: query
schema: { type: string, pattern: '^[0-9]{1,19}$' }
description: 'Only return tweets with an ID less than (older than) this numeric tweet ID. Non-numeric values are rejected with 400.'
- name: startTime
in: query
schema: { type: string, format: date-time }
description: 'Oldest UTC timestamp (ISO 8601, inclusive), within the last 7 days'
- name: endTime
in: query
schema: { type: string, format: date-time }
description: 'Newest UTC timestamp (ISO 8601, exclusive), within the last 7 days'
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor from a previous response
- name: sortOrder
in: query
schema: { type: string, enum: [recency, relevancy], default: recency }
responses:
'200':
description: Matching tweets
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweets:
type: array
items:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
conversationId: { type: string }
inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the result is itself a reply' }
lang: { type: string }
author:
type: object
properties:
id: { type: string }
username: { type: string }
displayName: { type: string }
avatar: { type: string }
verifiedType: { type: string }
likeCount: { type: integer }
replyCount: { type: integer }
retweetCount: { type: integer }
quoteCount: { type: integer }
platform: { type: string, example: twitter }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: [string, "null"] }
meta:
type: object
properties:
resultCount: { type: integer }
newestId: { type: [string, "null"] }
oldestId: { type: [string, "null"] }
platform: { type: string, example: twitter }
'400': { description: 'Bad request (invalid params, or X rejected the query as malformed)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { description: 'X API spend cap reached for this billing period' }
'403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED)' }
'404': { description: Account not found }
'429': { description: 'X search rate limit exceeded (300 requests per 15 minutes)' }
/v1/twitter/tweet:
get:
x-resource-group: "engagement"
operationId: getTweet
summary: Look up a tweet
description: |
Resolve a single tweet by ID or URL into its text, author and public metrics.
Use this to render a post you are referencing, e.g. the tweet quoted by a quote-style post.
Unlike `/v1/twitter/search` this is not limited to the last 7 days and works for any tweet
visible to the connected account.
Billed as an X posts read ($0.005). Repeat lookups of the same tweet within the same UTC day
are charged once.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The social account ID whose X token is used for the lookup
- name: id
in: query
required: true
schema: { type: string }
description: 'Numeric tweet ID or a tweet URL (e.g. https://x.com/user/status/123...)'
responses:
'200':
description: The resolved tweet
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweet:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
conversationId: { type: string }
inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the tweet is itself a reply' }
lang: { type: string }
author:
type: object
properties:
id: { type: string }
username: { type: string }
displayName: { type: string }
avatar: { type: string }
verifiedType: { type: string }
likeCount: { type: integer }
replyCount: { type: integer }
retweetCount: { type: integer }
quoteCount: { type: integer }
platform: { type: string, example: twitter }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { description: 'X API spend cap reached for this billing period' }
'403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED), or the tweet author is protected or suspended' }
'404': { description: 'Account not found, or the tweet was deleted or never existed' }
'429': { description: 'X rate limit exceeded' }
/v1/inbox/mentions:
get:
x-resource-group: "engagement"
operationId: listInboxMentions
summary: List mentions
description: |
Returns mentions of your connected organization accounts, delivered via platform webhooks.
Currently supports LinkedIn organization mentions.
Requires Inbox addon.
tags: [Mentions]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order by publishedAt
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- name: cursor
in: query
schema: { type: string }
description: Cursor for pagination (ID of the last item from the previous page)
responses:
'200':
description: Paginated list of mentions
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: Mention document ID
platform:
type: string
enum: [linkedin]
accountId:
type: string
accountUsername:
type: string
content:
type: string
description: Text of the post that mentioned you
permalink:
type: [string, "null"]
description: URL to the source post on LinkedIn
authorUrn:
type: [string, "null"]
description: LinkedIn URN of the person who mentioned you
authorName:
type: [string, "null"]
description: Display name of the author, resolved from authorUrn. Null when LinkedIn does not allow resolving the profile.
authorUsername:
type: [string, "null"]
description: LinkedIn vanity name of the author (the slug in their profile URL)
authorPicture:
type: [string, "null"]
description: Profile picture URL of the author. LinkedIn CDN URLs expire after some time, so fetch promptly rather than storing long-term.
organizationalEntity:
type: string
description: URN of the organization that was mentioned
publishedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
pagination:
type: object
properties:
hasMore:
type: boolean
cursor:
type: [string, "null"]
meta:
type: object
properties:
total:
type: integer
sortOrder:
type: string
enum: [asc, desc]
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Inbox addon required }
/v1/inbox/mentions/reply:
post:
x-resource-group: "engagement"
operationId: replyToMention
summary: Reply to a mention
description: |
Reply to a mention of the connected account. Supported on Instagram only.
Two shapes, selected by whether `commentId` is present:
- **Comment mention** (someone @mentioned the account inside a comment): pass both
`mediaId` and `commentId`. Instagram posts a reply under that comment.
- **Caption mention** (someone @mentioned the account in their media caption, so no
comment exists): pass `mediaId` only. Instagram posts a comment on their media.
Story mentions are not supported by Instagram's API.
Note that `GET /v1/inbox/mentions` currently returns LinkedIn mentions only and does
not surface Instagram mentions. Source `mediaId` and `commentId` from Instagram's
`comments` webhook, which is where mention notifications are delivered for accounts
connected through Instagram Login.
tags: [Mentions]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, mediaId, message]
properties:
accountId: { type: string, description: The Instagram social account ID }
mediaId: { type: string, description: The ID of the media the account was mentioned in }
commentId:
type: string
description: The mentioning comment's ID. Omit for a caption mention.
message: { type: string, description: The reply text }
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
mediaId: "17895695668004550"
commentId: "17870913561140000"
message: "Thanks for the shout-out!"
responses:
'200':
description: Reply posted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
id: { type: string, description: ID of the created reply or comment }
example:
success: true
id: "17912345678901234"
'400':
description: "Platform does not support replying to mentions (code: platform_not_supported), or missing mediaId/message."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404': { description: Account not found }
'502': { description: Instagram was unreachable or returned an unclassified error. Instagram 4xx statuses are forwarded as-is. }
/v1/inbox/reviews:
get:
x-resource-group: "engagement"
operationId: listInboxReviews
summary: List reviews
description: |
Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options.
Supported platforms: Facebook, Google Business.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string, enum: [facebook, googlebusiness] }
- name: minRating
in: query
schema: { type: integer, minimum: 1, maximum: 5 }
- name: maxRating
in: query
schema: { type: integer, minimum: 1, maximum: 5 }
- name: hasReply
in: query
schema: { type: boolean }
description: Filter by reply status
- name: sortBy
in: query
schema: { type: string, enum: [date, rating], default: date }
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
- name: cursor
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated reviews
content:
application/json:
schema:
type: object
properties:
status: { type: string }
data:
type: array
items:
type: object
properties:
id: { type: string, description: 'Review identifier. For Google Business this is the full review resource name (accounts/{accountId}/locations/{locationId}/reviews/{reviewId}), so it also encodes the location.' }
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
locationId: { type: string, description: 'Bare GBP location id the review belongs to. Google Business only; absent for other platforms.' }
locationName: { type: [string, "null"], description: 'Human-readable GBP location display name. Google Business only; absent for other platforms.' }
reviewer:
type: object
properties:
id: { type: [string, "null"] }
name: { type: string }
profileImage: { type: [string, "null"] }
rating: { type: integer }
text: { type: string }
created: { type: string, format: date-time }
hasReply: { type: boolean }
hasPhotos:
type: boolean
description: Whether the review has at least one photo. Google Business only; always false for other platforms.
photoCount:
type: integer
description: Number of photos attached to the review (photos only; videos are not counted). Google Business only; 0 for other platforms.
photos:
type: array
description: 'Photos attached to the review. Google Business only; always an empty array for other platforms.'
items:
type: object
properties:
url: { type: string, format: uri }
reply:
type: [object, "null"]
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
reviewUrl: { type: [string, "null"] }
pagination:
type: object
properties:
hasMore: { type: boolean }
nextCursor: { type: [string, "null"] }
meta:
type: object
properties:
accountsQueried: { type: integer }
accountsFailed: { type: integer }
failedAccounts:
type: array
items:
type: object
properties:
accountId: { type: string }
accountUsername: { type: [string, "null"] }
platform: { type: string }
error: { type: string }
code: { type: [string, "null"], description: Error code if available }
retryAfter: { type: [integer, "null"], description: Seconds to wait before retry (rate limits) }
lastUpdated: { type: string, format: date-time }
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.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: reviewId
in: path
required: true
schema: { type: string }
description: Review ID (URL-encoded for Google Business)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, message]
properties:
accountId: { type: string }
message: { type: string }
responses:
'200':
description: Reply posted
content:
application/json:
schema:
type: object
properties:
status: { type: string }
reply:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
platform: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
x-resource-group: "engagement"
operationId: deleteInboxReviewReply
summary: Delete review reply
description: Delete a reply to a review (Google Business only). Requires accountId in request body.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: reviewId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
responses:
'200':
description: Reply deleted
content:
application/json:
schema:
type: object
properties:
status: { type: string }
message: { type: string }
platform: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
# ──────────────────────────────────────────────────────────────────────────
# WHATSAPP-SPECIFIC ENDPOINTS
# Templates, business profile, phone numbers: ACTIVE (no cross-platform equivalent)
# ──────────────────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────────────────
# TEMPLATES
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/templates:
get:
x-resource-group: "accounts"
operationId: getWhatsAppTemplates
tags: [WhatsApp]
summary: List templates
description: |
List all message templates for the WhatsApp Business Account (WABA) associated with the given account.
Templates are fetched directly from the WhatsApp Cloud API.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Templates retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
templates:
type: array
items:
type: object
properties:
id: { type: string, description: WhatsApp template ID }
name: { type: string }
status: { type: string, enum: [APPROVED, PENDING, REJECTED] }
category: { type: string, enum: [AUTHENTICATION, MARKETING, UTILITY] }
language: { type: string }
components:
type: array
items:
type: object
'400': { description: accountId is required or WABA ID not found }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: createWhatsAppTemplate
tags: [WhatsApp]
summary: Create template
description: |
Create a new message template. Supports two modes:
Custom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h).
Library template: Provide library_template_name instead of components to use a pre-built template
from Meta's template library. Library templates are pre-approved (no review wait). You can optionally
customize parameters and buttons via library_template_body_inputs and library_template_button_inputs.
Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
- name
- category
- language
properties:
accountId:
type: string
description: WhatsApp social account ID
name:
type: string
pattern: "^[a-z][a-z0-9_]*$"
description: Template name (lowercase, letters/numbers/underscores, must start with a letter)
category:
type: string
enum: [AUTHENTICATION, MARKETING, UTILITY]
description: Template category
language:
type: string
description: Template language code (e.g., en_US)
parameter_format:
type: string
enum: [POSITIONAL, NAMED, positional, named]
description: 'Variable style: POSITIONAL ({{1}}, the default) or NAMED ({{customer_name}}). Named templates provide examples via body_text_named_params / header_text_named_params. Inferred as NAMED when omitted but a named-params example is present.'
components:
type: array
description: "Template components (header, body, footer, buttons, carousel, limited_time_offer). Required for custom templates, omit when using library_template_name."
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateComponent'
library_template_name:
type: string
description: |
Name of a pre-built template from Meta's template library (e.g., "appointment_reminder",
"auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved
by Meta with no review wait. Omit components when using this field.
library_template_body_inputs:
type: object
description: |
Optional body customizations for library templates. Available options depend on the
template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation,
add_track_package_link, code_expiration_minutes).
library_template_button_inputs:
type: array
description: |
Optional button customizations for library templates. Each item specifies button type
and configuration (e.g., URL, phone number, quick reply).
items:
type: object
properties:
type:
type: string
enum: [quick_reply, url, phone_number]
url:
type: object
properties:
base_url: { type: string }
phone_number:
type: string
examples:
custom:
summary: Custom template (requires review)
value:
accountId: "507f1f77bcf86cd799439011"
name: "order_confirmation"
category: "UTILITY"
language: "en_US"
components:
- type: "header"
format: "image"
example:
header_handle: ["https://example.com/header.jpg"]
- type: "body"
text: "Your order {{1}} has been confirmed. Expected delivery: {{2}}"
example:
body_text: [["ORD-12345", "March 31"]]
- type: "footer"
text: "Thank you for your purchase"
- type: "buttons"
buttons:
- type: "quick_reply"
text: "Track Order"
library:
summary: Library template (pre-approved, no review)
value:
accountId: "507f1f77bcf86cd799439011"
name: "my_appointment_reminder"
category: "UTILITY"
language: "en_US"
library_template_name: "appointment_reminder"
library_template_button_inputs:
- type: "url"
url:
base_url: "https://myapp.com/appointments/{{1}}"
responses:
'200':
description: Template created (pre-approved for library templates, pending review for custom)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string, description: "APPROVED for library templates, PENDING for custom" }
category: { type: string }
language: { type: string }
'400': { description: "Validation error (invalid name format, missing fields, invalid category)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/templates/{templateName}:
get:
x-resource-group: "accounts"
operationId: getWhatsAppTemplate
tags: [WhatsApp]
summary: Get template
description: |
Retrieve a single message template by name.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Template retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string }
category: { type: string }
language: { type: string }
components:
type: array
items:
type: object
'400': { description: accountId is required }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'502': { description: Meta rejected the request or was unreachable. Meta 4xx statuses are forwarded as-is. }
patch:
x-resource-group: "accounts"
operationId: updateWhatsAppTemplate
tags: [WhatsApp]
summary: Update template
description: |
Update a message template's components. Only certain fields can be updated depending on
the template's current approval state. Approved templates can only have components updated.
A successful update sends the template back to Meta for review, so the `status` returned
here is normally `PENDING`. The final outcome arrives later on the
`whatsapp.template.status_updated` webhook. A template already in `PENDING` cannot be
edited again until Meta finishes reviewing it.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
- components
properties:
accountId:
type: string
description: WhatsApp social account ID
components:
type: array
description: Updated template components
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateComponent'
example:
accountId: "507f1f77bcf86cd799439011"
components:
- type: "body"
text: "Updated: Your order {{1}} is confirmed. Delivery by {{2}}"
example:
body_text: [["ORD-12345", "April 1"]]
- type: "buttons"
buttons:
- type: "quick_reply"
text: "Track Order"
responses:
'200':
description: Template updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status:
type: string
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': { description: Validation error (missing fields) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'502': { description: Meta rejected the update or was unreachable. Meta 4xx statuses are forwarded as-is. }
delete:
x-resource-group: "accounts"
operationId: deleteWhatsAppTemplate
tags: [WhatsApp]
summary: Delete template
description: |
Permanently delete a message template by name.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Template deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Template \"order_confirmation\" deleted successfully"
'400': { description: accountId or template name is required }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'502': { description: Meta rejected the delete or was unreachable. Meta 4xx statuses are forwarded as-is. }
# ──────────────────────────────────────────────────────────────────────────
# WHATSAPP BUSINESS CALLING
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/calling:
get:
x-resource-group: "accounts"
operationId: getWhatsAppCallingConfig
tags: [WhatsApp Calling]
summary: Get calling config for an account
description: |
Returns the local calling configuration snapshot for the connected
WhatsApp account: whether calling is enabled, the forward-to
destination URI, recording opt-in state, the phone number record id
(use as `{id}` on the read-write calling sub-resource at
/v1/phone-numbers/{id}/whatsapp/calling) and whether SIP digest
credentials are stored (the encrypted password itself is never
returned). Also carries account-level extras (billing eligibility,
current-period spend) that the number-keyed GET does not.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Calling config
content:
application/json:
schema:
type: object
properties:
phoneNumberDocId: { type: string, description: "Phone number record ID (use on /v1/phone-numbers/{id}/whatsapp/calling)" }
phoneNumber: { type: string }
callingEnabled: { type: boolean }
callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Tapping it on a phone starts a WhatsApp voice call to this number. Embed it on websites, emails, or QR codes. Null while calling is disabled; not supported by WhatsApp desktop clients." }
forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
recordingEnabled: { type: boolean }
sipAuthUsername: { type: [string, "null"] }
sipAuthPasswordConfigured: { type: boolean, description: "True when a SIP digest password is stored. The plaintext is never returned." }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID; verify via /v1/phone-numbers/{id}/whatsapp/caller-id-verification).'
callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification.' }
maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
forwardCallerId: { type: string, enum: [business, caller] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found for this account }
/v1/whatsapp/phone-numbers/{id}/calling:
post:
x-resource-group: "telephony"
operationId: enableWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Enable calling on a number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Enable WhatsApp Business Calling on a connected number. Configures
Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
stores the Meta-issued SIP password (encrypted), and snapshots the
customer's forward-to destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber Mongo ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, forwardTo]
properties:
accountId: { type: string }
forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
sipAuthUsername: { type: string }
sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
recordingEnabled: { type: boolean, default: false }
callIconCountries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
responses:
'200':
description: Calling enabled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callingEnabled: { type: boolean }
sipHostname: { type: string }
forwardTo: { type: string }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
'400': { description: 'Invalid request (including forwardTo set to the number itself)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
'422': { description: "Not eligible to enable calling: not on usage-based billing, or the number's messaging limit is below Meta's ~2,000-daily-recipient threshold (TIER_250). Warm the number up to raise the limit." }
patch:
x-resource-group: "telephony"
operationId: updateWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Update calling config
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Update fields on an already-enabled number. Only fields present in
the body are written; `undefined` leaves the stored value alone,
explicit `null` clears a nullable field. No Meta side effect, this
only changes local routing state consumed by the Telnyx webhook
handler.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
forwardTo: { type: string }
sipAuthUsername: { type: [string, "null"] }
sipAuthPassword: { type: [string, "null"] }
recordingEnabled: { type: boolean }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: [integer, "null"], minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on forwarded calls; null clears the cap.' }
forwardCallerId: { type: string, enum: [business, caller], description: 'caller = present the WhatsApp user''s number to the forward destination (sip: only).' }
responses:
'200': { description: Updated }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
'422': { description: Calling must be enabled before settings can be updated }
delete:
x-resource-group: "telephony"
operationId: disableWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Disable calling on a number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Disable calling. Sends calling.status=DISABLED to Meta (best-effort)
and flips the local `callingEnabled` flag off. forwardTo and SIP
creds are preserved so a re-enable does not lose the destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200': { description: Disabled }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
/v1/whatsapp/call-permissions:
get:
x-resource-group: "contacts"
operationId: getWhatsAppCallPermissions
tags: [WhatsApp Calling]
summary: Check call permission
description: |
Returns the permission state and the list of available actions for
a given consumer wa_id (e.g. `start_call`, `send_call_permission_request`).
Use this before placing a call to decide whether to prompt for
consent first.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: to, in: query, required: true, schema: { type: string }, description: "Consumer wa_id (E.164, leading + optional)" }
responses:
'200':
description: Permission state
content:
application/json:
schema:
type: object
properties:
permission:
type: object
properties:
status: { type: string, enum: [temporary, no_permission, permanent] }
expiration_time: { type: integer, description: Unix seconds when temporary }
actions:
type: array
items:
type: object
properties:
action_name: { type: string, enum: [send_call_permission_request, start_call] }
can_perform_action: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/calls:
post:
x-resource-group: "messages"
operationId: initiateWhatsAppCall
tags: [WhatsApp Calling]
summary: Initiate outbound call
description: |
Initiates an outbound Business-Initiated Call. The Telnyx-side SIP
leg is originated server-side (Option B: SIP-first). Telnyx INVITEs
Meta directly over TLS:5061 with the SIP digest credentials we
captured at calling-enablement time). No client-side SDP is
required; pass only `accountId` and `to`.
To send the consumer the call-consent prompt instead of placing a
call, pass `action: "send_call_permission_request"` (+ optional
`bodyText`). The consumer must tap Allow in WhatsApp before
`start_call` is permitted; Meta limits the prompt to 1 per consumer
per 24h (2 per 7 days) and requires an open 24h service window.
**Idempotency:** send an `Idempotency-Key` header to make retries
safe; same key + same body replays the original response instead of
dialing (and billing) a second call.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, to]
properties:
accountId: { type: string }
to: { type: string, description: "Consumer wa_id (E.164, leading + optional)" }
action:
type: string
enum: [send_call_permission_request]
description: Omit to place a call. Set to send the consent prompt instead.
bodyText:
type: string
maxLength: 1024
description: Body text shown with the consent prompt (send_call_permission_request only).
forwardTo:
type: string
description: |
Per-call destination override. Same accepted shape as the
number's stored forwardTo (tel:+E164, sip:..., wss://...).
recordOverride: { type: boolean }
biz_opaque_callback_data:
type: string
maxLength: 512
description: |
Accepted for forward compatibility. Not currently echoed
back in webhook payloads (SIP-first flow does not pass
through Meta's Graph API where Meta would echo this).
responses:
'200':
description: Call originated; lifecycle continues asynchronously via webhooks.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string, description: Internal Call doc ID }
telnyxCallControlId: { type: string, description: Telnyx call_control_id of the outbound leg }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
to: { type: string }
forwardTo: { type: [string, "null"] }
recordingEnabled: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: No active call permission — send a permission request first. }
'422': { description: "Calling not enabled, BIC country blocked, or missing Meta SIP credentials" }
'502': { description: Telnyx-side originate failed; the Call doc has been marked failed. }
get:
x-resource-group: "messages"
operationId: listWhatsAppCalls
tags: [WhatsApp Calling]
summary: List call history for an account
description: |
Compact history listing for a single connected account. Results are
scoped to the resolved SocialAccount; profile-scoped team members
cannot read calls on sibling accounts.
Cursor pagination: pass the returned `nextCursor` as `before` to fetch
the next page (same scheme as `GET /v1/calls`). `since`/`until` remain
as absolute range filters and combine with the cursor.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: since, in: query, schema: { type: string, format: date-time } }
- { name: until, in: query, schema: { type: string, format: date-time } }
- { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
responses:
'200':
description: Calls
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
type: object
properties:
_id: { type: string }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string }
to: { type: string }
status: { type: string, enum: [ringing, answered, ended, failed] }
startedAt: { type: string, format: date-time }
endedAt: { type: string, format: date-time }
durationSeconds: { type: integer }
endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
recordingUrl: { type: string }
billing:
type: object
properties:
metaCostUSD: { type: number, description: 'Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
totalCostUSD: { type: number, description: 'Full cost incl. the Meta portion you pay directly. Display only.' }
currency: { type: string }
nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/calls/{id}:
get:
x-resource-group: "messages"
operationId: getWhatsAppCall
tags: [WhatsApp Calling]
summary: Get a single call
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
type: object
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
/v1/whatsapp/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getWhatsAppCallRecording
tags: [WhatsApp Calling]
summary: Get a call recording
description: |
Resolves a fresh, playable MP3 URL for the call's recording.
Provider-signed recording URLs expire ~10 minutes after signing, so the
`recordingUrl` stored on the call is usually stale by the time it is
played; this endpoint re-signs on demand. Default responds `302 Found`
redirecting to the fresh URL (point an `<audio>` element or a link
straight at this endpoint); pass `as=json` to receive `{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/whatsapp/calls/estimate:
get:
x-resource-group: "messages"
operationId: getWhatsAppCallEstimate
tags: [WhatsApp Calling]
summary: Estimate per-minute cost
description: |
Returns a zero-markup estimated cost for an outbound call to the
given destination, broken down by Meta + Telnyx + recording line
items. Costs are pass-through, no margin applied.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: to, in: query, required: true, schema: { type: string } }
- { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120 } }
- { name: recording, in: query, schema: { type: boolean } }
responses:
'200':
description: Estimate
content:
application/json:
schema:
type: object
properties:
destinationCountry: { type: [string, "null"] }
perMinuteUsd: { type: number }
breakdown:
type: object
properties:
metaMinutes: { type: integer }
metaCostUSD: { type: number, description: 'Estimated Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Estimated amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
totalCostUSD: { type: number, description: 'Estimated full cost incl. the Meta portion you pay directly. Display only.' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/calls:
get:
x-resource-group: "messages"
operationId: listCalls
tags: [Calls]
summary: List all calls (unified history)
description: |
Unified call history across ALL of your numbers: both channels
(WhatsApp Business Calling + regular phone/PSTN), inbound and outbound,
newest first. Unlike `GET /v1/voice/calls` (PSTN-only) and
`GET /v1/whatsapp/calls` (one account at a time), this endpoint needs no
`accountId` and never requires fanning out one request per number.
Any row can be opened channel-agnostically via `GET /v1/calls/{id}` and
`GET /v1/calls/{id}/recording`; no branching on `channel` needed. When
the counterparty number matches a CRM contact, `contactId` and
`contactName` are set.
Cursor pagination: pass the returned `nextCursor` as `before` to fetch
the next page. `nextCursor` is null on the last page.
security:
- bearerAuth: []
parameters:
- { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of YOUR numbers, to scope history to a single line). E.164, leading + optional." }
- { name: search, in: query, schema: { type: string }, description: "Free-text match on the from/to numbers. Non-digits are stripped, so partial queries like `302` or `+1 302` work." }
- { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
responses:
'200':
description: Calls, newest first
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
allOf:
- $ref: '#/components/schemas/CallRecord'
- type: object
properties:
contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Not enrolled in the calling beta }
/v1/calls/{id}:
get:
x-resource-group: "messages"
operationId: getCall
tags: [Calls]
summary: Get a call (any channel)
description: |
Channel-agnostic call detail: works for both WhatsApp and regular
phone (PSTN) calls, so any row from `GET /v1/calls` can be opened
without branching on `channel`. Returns the full call including
transcript segments, with `contactId`/`contactName` set when the
counterparty matches a CRM contact.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
allOf:
- $ref: '#/components/schemas/CallRecord'
- type: object
properties:
contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Not enrolled in the calling beta }
'404': { description: Call not found }
/v1/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getCallRecording
tags: [Calls]
summary: Get a call recording
description: |
Channel-agnostic recording fetch: resolves a fresh, playable MP3 URL
for any call regardless of channel (provider-signed URLs expire ~10
minutes after signing, so this re-signs on demand). Default responds
`302 Found` redirecting to the fresh URL; pass `as=json` to receive
`{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Not enrolled in the calling beta }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/voice/calls:
post:
x-resource-group: "messages"
operationId: createVoiceCall
tags: [Voice]
summary: Place an outbound phone call
description: |
Dials `to` FROM one of your voice-enabled numbers and, on answer,
bridges the callee to the number's stored forward destination, or to
the per-call `forwardTo` override. Destinations can be your own AI
voice agent (Vapi/Retell), a phone, or a SIP endpoint. An optional
`greeting` is spoken to the callee before the bridge.
The 200 response means the call is dialing; the lifecycle continues
asynchronously (track it via `GET /v1/voice/calls/{id}` or the `call.*`
webhooks). Outbound calls are capped per rolling hour (429 when hit).
**Idempotency:** send an `Idempotency-Key` header to make retries safe;
same key + same body replays the original response instead of dialing
(and billing) a second call.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, pattern: '^\+\d{6,15}$', description: "Destination to dial, E.164 with leading +." }
fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to dial from. Optional when you have exactly one. }
forwardTo: { type: string, description: "Per-call agent override (tel:+E164, sip:..., or wss://...); defaults to the number's stored forward destination." }
greeting: { type: string, maxLength: 1000, description: "Spoken to the callee when they answer, before the bridge." }
recordOverride: { type: boolean, description: Per-call recording toggle; defaults to the number's setting. }
transcribeOverride: { type: boolean, description: Per-call transcription toggle; defaults to the number's setting. }
transcriptionLanguage: { type: string, enum: [auto, en, es], description: "'auto' derives from the callee's country; 'en'/'es' force it." }
amd: { type: boolean, description: Answering-machine detection; defers the bridge until human vs machine is known. }
voicemailDropMessage: { type: string, maxLength: 1000, description: "Spoken to a detected machine, then hang up (implies `amd`). For outbound voicemail drops." }
responses:
'200':
description: Call originated; lifecycle continues asynchronously.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string, description: Internal Call doc ID }
telnyxCallControlId: { type: string }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
from: { type: string }
to: { type: string }
forwardTo: { type: string }
greeting: { type: [string, "null"] }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "No voice-enabled number matches `fromNumber`, or no forward destination configured (set the number's forward or pass `forwardTo`)." }
'429': { description: Outbound call limit reached (per rolling hour). }
'502': { description: Carrier-side originate failed; the call has been marked failed. }
get:
x-resource-group: "messages"
operationId: listVoiceCalls
tags: [Voice]
summary: List phone calls
description: |
Your PSTN voice calls (inbound + outbound), newest first. Cursor
pagination: pass the returned `nextCursor` as `before` for the next
page. For a history that also includes WhatsApp calls, use
`GET /v1/calls`.
security:
- bearerAuth: []
parameters:
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of your DIDs). E.164, leading + optional." }
- { name: before, in: query, schema: { type: string, format: date-time } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
responses:
'200':
description: Calls, newest first
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
$ref: '#/components/schemas/CallRecord'
nextCursor: { type: [string, "null"], format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/voice/calls/{id}:
get:
x-resource-group: "messages"
operationId: getVoiceCall
tags: [Voice]
summary: Get a phone call
description: Full call detail, including the transcript segments when transcription was on.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
$ref: '#/components/schemas/CallRecord'
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
/v1/voice/calls/{id}/end:
post:
x-resource-group: "messages"
operationId: endVoiceCall
tags: [Voice]
summary: Hang up a live call
description: |
Hangs up a live call on demand. Idempotent: ending a call that already
ended (or never connected) returns success with the call's current
status. Final duration/cost are written asynchronously when the hangup
event lands, so the call doc may briefly still show its prior status.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Hangup issued (or the call was already over).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
status: { type: string, description: "`ending` when a hangup was issued; otherwise the call's current status." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
'502': { description: Carrier-side hangup failed }
/v1/voice/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getVoiceCallRecording
tags: [Voice]
summary: Get a call recording
description: |
Resolves a fresh, playable MP3 URL for the call's recording
(provider-signed URLs expire ~10 minutes after signing, so this
endpoint re-signs on demand). Default responds `302 Found` redirecting
to the fresh URL; pass `as=json` to receive `{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/voice/calls/{id}/transfer:
post:
x-resource-group: "messages"
operationId: transferVoiceCall
tags: [Voice]
summary: Blind-transfer a live call
description: |
Moves the call's current leg to a new destination (a phone number or a
SIP endpoint). This is a BLIND transfer: control of the leg is handed
off and the call ends normally when the transferred leg hangs up. The
caller ID presented on the transfer leg is always your own number.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, description: "+E164 phone number (tel: prefix optional) or a sip: URI. wss:// is not a valid transfer target." }
responses:
'200':
description: Transfer issued.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
transferredTo: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
'409': { description: "Call is not connected yet, or has already ended" }
/v1/voice/calls/estimate:
get:
x-resource-group: "messages"
operationId: getVoiceCallEstimate
tags: [Voice]
summary: Estimate call cost
description: |
Pre-call cost estimate for a PSTN call: the carrier leg plus optional
recording and transcription add-ons. Same billing formula as the
post-call invoice, so the quote and the final charge can't disagree.
The per-minute figure is deliberately conservative (the real cost
comes from the settled carrier record after the call), so estimates
trend slightly over the actual invoice. Parity endpoint of
`GET /v1/whatsapp/calls/estimate`, minus the Meta line (PSTN calls
have no separate Meta bill, so `totalCostUSD` equals
`billableCostUSD`).
security:
- bearerAuth: []
parameters:
- { name: to, in: query, required: true, schema: { type: string }, description: "Destination number, E.164 (leading + optional)." }
- { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120, default: 1 } }
- { name: recording, in: query, schema: { type: boolean } }
- { name: transcription, in: query, schema: { type: boolean } }
responses:
'200':
description: Estimate
content:
application/json:
schema:
type: object
properties:
destinationCountry: { type: [string, "null"] }
minutes: { type: integer }
perMinuteUsd: { type: number, description: Billable cost per minute for the requested options. }
breakdown:
type: object
properties:
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
transcriptionCostUSD: { type: number }
billableCostUSD: { type: number, description: What Zernio bills for the call. }
totalCostUSD: { type: number, description: Equals billableCostUSD (no separate Meta bill on PSTN); kept for shape parity with the WhatsApp estimate. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/voice/calls/web:
post:
x-resource-group: "messages"
operationId: createVoiceWebSession
tags: [Voice]
summary: Mint a browser softphone session
description: |
Step 1 of the two-step browser softphone handshake. Mints a WebRTC
session (token + credential) the browser registers with the
`@telnyx/webrtc` SDK. Once registered, call
`POST /v1/voice/calls/web/dial` with the returned `credentialId` to
place the call. The split avoids bridging to a browser that has not
finished registering. The token lives ~1 hour (it must outlive the
whole call, not just the handshake).
security:
- bearerAuth: []
responses:
'200':
description: WebRTC session minted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
token: { type: string, description: "Login token for the browser WebRTC SDK." }
credentialId: { type: string, description: "Pass to POST /v1/voice/calls/web/dial once the browser is registered." }
expiresAt: { type: string, format: date-time }
sdk: { type: string, example: '@telnyx/webrtc' }
'401': { $ref: '#/components/responses/Unauthorized' }
'502': { description: Failed to mint the WebRTC session }
/v1/voice/calls/web/dial:
post:
x-resource-group: "messages"
operationId: dialVoiceWebCall
tags: [Voice]
summary: Dial from the browser softphone
description: |
Step 2 of the browser softphone handshake: places an outbound call
whose answered leg is bridged to the browser registered with the
credential from `POST /v1/voice/calls/web`. The call runs through the
normal outbound lane, so it is logged as outbound (from = your number,
to = target) and recorded per the number's settings.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to, credentialId]
properties:
to: { type: string, pattern: '^\+\d{6,15}$', description: "The number to call, E.164 with leading +." }
credentialId: { type: string, description: "The WebRTC credential id returned by POST /v1/voice/calls/web (the registered browser)." }
fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to call from (optional when you have one). }
recordOverride: { type: boolean }
responses:
'200':
description: Call originated; answer/bridge continue asynchronously.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
telnyxCallControlId: { type: string }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
from: { type: string }
to: { type: string }
recordingEnabled: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "Invalid or unknown WebRTC credential, or no voice-enabled number matches `fromNumber`" }
'429': { description: Outbound call limit reached (per rolling hour). }
'502': { description: Carrier-side originate failed }
/v1/sms/messages:
post:
x-resource-group: "messages"
operationId: sendSms
tags: [SMS]
summary: Send an SMS/MMS
description: |
Sends an SMS (or MMS when `mediaUrls` is set) from one of your
SMS-enabled numbers. At least one of `text` / `mediaUrls` is required.
Both numbers are normalized to E.164, so `from` matches regardless of
formatting and replies thread into the same inbox conversation.
US numbers must have an approved carrier registration
(`/v1/sms/registrations`) before messages deliver.
**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
workspaces. Creating the same sender ID again is a no-op
(re-activates it after a delete).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [senderId]
properties:
senderId:
type: string
minLength: 3
maxLength: 11
description: 'The sender ID recipients will see (3-11 letters/digits/spaces, at least one letter, no leading/trailing space).'
responses:
'200':
description: Sender ID created (or re-activated).
content:
application/json:
schema:
type: object
properties:
id: { type: string, description: Sender ID resource id. }
senderId: { type: string }
isActive: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Workspace is not on usage-based billing, or already holds the maximum of 1,000 active sender IDs (code `sender_id_limit_reached`; raisable via support).' }
'422': { description: 'Sender ID rejected: it appears to impersonate a protected brand or institution.' }
get:
x-resource-group: "telephony"
operationId: listSmsSenderIds
tags: [SMS]
summary: List alphanumeric sender IDs
security:
- bearerAuth: []
responses:
'200':
description: The workspace's sender IDs, newest first.
content:
application/json:
schema:
type: object
properties:
senderIds:
type: array
items:
type: object
properties:
id: { type: string }
senderId: { type: string }
isActive: { type: boolean }
createdAt: { type: [string, "null"], format: date-time }
budget:
type: object
description: 'Workspace-wide daily sending budget, shared by every sender ID (resets midnight UTC).'
properties:
cap: { type: integer, description: 'Daily message cap (raisable via `/v1/sms/sender-ids/limit-request`).' }
usedToday: { type: integer, description: Messages already counted against today's cap. }
level: { type: integer, description: 'Cap tier (Level 1 = 500/day).' }
pendingRequest:
type: [object, "null"]
description: 'The in-flight cap-raise request awaiting review, or null. While set, further requests return 409.'
properties:
requestedCap: { type: integer }
level: { type: integer }
requestedAt: { type: [string, "null"], format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sms/sender-ids/limit-request:
post:
x-resource-group: "telephony"
operationId: requestSmsSenderIdLimitIncrease
tags: [SMS]
summary: Request a higher sender ID daily limit
description: |
Asks support to raise the workspace's daily sender-ID message cap.
There is no self-serve raise: the request (desired cap + use case) is
reviewed manually, usually within a business day.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [requestedCap, reason]
properties:
requestedCap: { type: integer, description: 'Desired daily message cap. Must exceed the current cap.' }
reason: { type: string, minLength: 10, maxLength: 2000, description: 'Use case and audience (what you send, to whom, opt-in status).' }
responses:
'200':
description: Request submitted for review.
content:
application/json:
schema:
type: object
properties:
requested: { type: boolean }
requestedCap: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: 'A cap-raise request is already awaiting review (code `sender_id_raise_pending`); one at a time.' }
'503': { description: Request could not be submitted; retry or contact support. }
/v1/sms/sender-ids/{id}:
delete:
x-resource-group: "telephony"
operationId: deleteSmsSenderId
tags: [SMS]
summary: Delete an alphanumeric sender ID
description: |
Deactivates the sender ID so it can no longer send. Re-creating the
same sender ID via `POST /v1/sms/sender-ids` re-activates it.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Sender ID resource id. }
responses:
'200':
description: Sender ID deactivated.
content:
application/json:
schema:
type: object
properties:
deleted: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Sender ID not found. }
/v1/sms/registrations:
post:
x-resource-group: "telephony"
operationId: startSmsRegistration
tags: [SMS]
summary: Start a carrier registration
description: |
Starts the US carrier registration that a number needs before SMS
delivers: 10DLC (standard company or sole-proprietor) or toll-free
verification. 10DLC needs `brand` + `campaign`; toll-free needs
`tollFree`. Approval is asynchronous; poll
`GET /v1/sms/registrations/{id}` (sole-prop registrations first need
the OTP step: a code is texted to the brand's mobile number, submit it
via `/verify-otp`).
Already have an approved registration? Add another number to it with
`POST /v1/phone-numbers/{id}/sms/reuse-registration` instead
of registering (and paying the carrier brand fee) again.
Rather have your client fill in the legal business details? Create a
share link with `POST /v1/sms/registrations/share`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [registrationType]
properties:
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
phoneNumbers:
type: array
items: { type: string }
description: 'Your numbers this registration covers. When omitted or empty on a 10DLC registration, defaults to your active SMS-enabled US local numbers not already covered by another registration.'
brand:
type: object
description: Required for 10DLC. The legal entity behind the traffic (TCR brand).
required: [entityType, displayName, country, vertical, website, street, city, state, postalCode]
properties:
entityType: { type: string, enum: [PRIVATE_PROFIT, PUBLIC_PROFIT, NON_PROFIT, GOVERNMENT, SOLE_PROPRIETOR] }
displayName: { type: string }
companyName: { type: string, description: Legal company name. Required for every entityType except SOLE_PROPRIETOR. }
ein: { type: string, description: Required for every entityType except SOLE_PROPRIETOR. }
phone: { type: string, description: Business contact phone. Required for every entityType except SOLE_PROPRIETOR. }
mobilePhone: { type: string, description: Required for SOLE_PROPRIETOR; the verification OTP is texted there (US/CA mobile). }
street: { type: string }
city: { type: string }
state: { type: string }
postalCode: { type: string }
country: { type: string, minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country where the company is registered. Companies worldwide can register standard 10DLC (non-US companies use their local tax ID in `ein`; carrier vetting may take longer). SOLE_PROPRIETOR is US/CA only." }
email: { type: string, format: email, description: Brand contact email; defaults to your account email when omitted. }
website: { type: string, description: "The brand's website (sole proprietors may use a social profile such as LinkedIn or a business Facebook page). Carriers verify the brand against it; a bare domain is normalized to https://." }
vertical: { type: string, enum: [AGRICULTURE, COMMUNICATION, CONSTRUCTION, EDUCATION, ENERGY, ENTERTAINMENT, FINANCIAL, GAMBLING, GOVERNMENT, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, LEGAL, MANUFACTURING, NGO, POLITICAL, POSTAL, PROFESSIONAL, REAL_ESTATE, RETAIL, TECHNOLOGY, TRANSPORTATION] }
stockSymbol: { type: string }
campaign:
type: object
description: |
Required for 10DLC. What you'll send and how recipients opt in/out.
The opt-in/opt-out/help auto-responses (`optinMessage`,
`optoutMessage`, `helpMessage`) are optional: when omitted, a
compliant, brand-named template with the carrier-required
disclosures is generated for you. If you do send them, they must
name the registered brand and carry the disclosures — submissions
that don't are rewritten to the compliant template before the
campaign is filed.
required: [usecase, description, messageFlow, sample1, sample2, optinKeywords, optoutKeywords, helpKeywords]
properties:
usecase: { type: string }
subUsecases:
type: array
minItems: 2
maxItems: 5
items: { type: string, enum: [2FA, ACCOUNT_NOTIFICATION, CUSTOMER_CARE, DELIVERY_NOTIFICATION, FRAUD_ALERT, HIGHER_EDUCATION, MARKETING, POLLING_VOTING, PUBLIC_SERVICE_ANNOUNCEMENT, SECURITY_ALERT] }
description: |
The concrete kinds of messages a MIXED campaign sends (the carrier
registry requires 2-5, and reviewers match them against the sample
messages). Omitted: a default pair is applied for MIXED.
description: { type: string, minLength: 40, maxLength: 4096 }
messageFlow: { type: string, minLength: 40, maxLength: 2048, description: "How a recipient ends up receiving your messages (the opt-in flow). Include a link to the page or form where they opt in — carrier reviewers reject campaigns whose consent they can't verify." }
sample1: { type: string, minLength: 20 }
sample2: { type: string, minLength: 20, description: "Second example message; carriers require two distinct samples, so it must differ from sample1." }
helpMessage: { type: string, minLength: 20, maxLength: 320 }
optinKeywords: { type: string }
optinMessage: { type: string, minLength: 20, maxLength: 320 }
optoutKeywords: { type: string }
optoutMessage: { type: string, minLength: 20, maxLength: 320 }
helpKeywords: { type: string }
embeddedLink: { type: boolean, description: "Whether messages carry links. Auto-derived from the samples when omitted, so the declaration matches what the reviewer reads." }
embeddedPhone: { type: boolean, description: "Whether messages carry phone numbers. Auto-derived from the samples when omitted." }
numberPool: { type: boolean }
ageGated: { type: boolean }
directLending: { type: boolean }
privacyPolicyLink: { type: string, description: "Link to your privacy policy. Recommended: reviewers check that it says mobile information is not sold or shared with third parties for promotional purposes. A bare domain is normalized to https://." }
termsAndConditionsLink: { type: string, description: "Link to your terms & conditions. A bare domain is normalized to https://." }
messagingBrandName:
type: string
minLength: 2
maxLength: 60
description: 'DBA / trade name used to brand message content (samples and auto-replies) when it differs from the legal name, e.g. a sole proprietor texting under a business name. The legal `brand.displayName` is still what the carrier vets.'
wizardValues:
type: object
additionalProperties: { type: string }
description: Raw dashboard-wizard answers, stored only to prefill edit-and-resubmit. API integrators can omit.
resubmitRequestId: { type: string, description: "Resubmit a registration that was returned for changes — updates it in place instead of creating a new one." }
tollFree:
type: object
description: Required for toll_free.
required: [businessName, corporateWebsite, phoneNumbers, useCase, useCaseSummary, productionMessageContent, optInWorkflow, optInWorkflowImageUrls, messageVolume, additionalInformation, businessAddr1, businessCity, businessState, businessZip, businessContactFirstName, businessContactLastName, businessContactEmail, businessContactPhone, businessRegistrationNumber, businessRegistrationType, businessRegistrationCountry]
properties:
businessName: { type: string }
corporateWebsite: { type: string, format: uri }
phoneNumbers: { type: array, minItems: 1, items: { type: string } }
useCase: { type: string }
useCaseSummary: { type: string }
productionMessageContent: { type: string }
optInWorkflow: { type: string, maxLength: 500, description: How recipients opt in to your messages. }
optInWorkflowImageUrls:
type: array
minItems: 1
items: { type: string, format: uri }
description: Screenshot URL(s) showing the opt-in flow (at least one).
messageVolume: { type: string, enum: ["10", "100", "1,000", "10,000", "100,000", "250,000", "500,000", "750,000", "1,000,000", "5,000,000", "10,000,000+"], description: Expected monthly message volume tier. }
additionalInformation: { type: string }
businessAddr1: { type: string }
businessAddr2: { type: string }
businessCity: { type: string }
businessState: { type: string }
businessZip: { type: string }
businessContactFirstName: { type: string }
businessContactLastName: { type: string }
businessContactEmail: { type: string, format: email }
businessContactPhone: { type: string }
businessRegistrationNumber: { type: string }
businessRegistrationType: { type: string, description: "e.g. EIN (US), Companies House (UK), ABN (AU)." }
businessRegistrationCountry: { type: string, description: ISO 3166-1 alpha-2. }
responses:
'200':
description: Registration submitted.
content:
application/json:
schema:
type: object
properties:
registrationId: { type: string }
status: { type: string, enum: [pending] }
awaitingOtp: { type: boolean, description: "True for sole-prop 10DLC: an OTP was texted to the brand's mobile; submit it via /verify-otp." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: Carrier registry rejected a field; `param` names it when known. }
get:
x-resource-group: "telephony"
operationId: listSmsRegistrations
tags: [SMS]
summary: List carrier registrations
security:
- bearerAuth: []
parameters:
- name: includeDeactivated
in: query
required: false
schema: { type: boolean }
description: Deactivated (terminated) registrations are hidden by default — pass true to include them.
responses:
'200':
description: Registrations, newest first
content:
application/json:
schema:
type: object
properties:
registrations:
type: array
items:
type: object
properties:
id: { type: string }
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
displayName: { type: [string, "null"] }
status: { type: string, enum: [pending, approved, rejected, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
brandStatus: { type: string, description: Carrier-registry brand status (e.g. VERIFIED). }
campaignStatus: { type: string }
brandId: { type: [string, "null"], description: "TCR brand id, useful when referencing the brand in carrier support threads." }
campaignId: { type: [string, "null"], description: TCR campaign id. }
declineReason: { type: [string, "null"] }
tfActionRequiredAt: { type: [string, "null"], format: date-time, description: 'Toll-free only: when the carrier requested changes ("Waiting For Customer"). The request must be resubmitted within 7 days of this timestamp or it expires.' }
phoneNumbers: { type: array, items: { type: string } }
awaitingOtp: { type: boolean, description: Sole-prop 10DLC only; the OTP step is still pending. }
trustScore: { type: [number, "null"], description: Carrier-assigned brand trust score; drives throughput. }
throughput:
type: object
description: Carrier throughput tier derived from the trust score.
properties:
label: { type: string }
smsPerMinute: { type: number }
smsPerDay: { type: number }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sms/registrations/preflight:
post:
x-resource-group: "telephony"
operationId: preflightSmsRegistration
tags: [SMS]
summary: Pre-check a carrier registration
description: |
Dry-run of `POST /v1/sms/registrations` for 10DLC: validates and
composes the exact brand/campaign payloads a submission would store
(branding, disclosures, auto-replies), runs deterministic compliance
lints plus an AI reviewer over them, and returns the findings WITHOUT
creating anything. Use it to fix issues before submitting; `block`
severity findings indicate a near-certain carrier rejection.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [registrationType, brand, campaign]
properties:
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc] }
phoneNumbers: { type: array, items: { type: string } }
brand: { type: object, description: 'Same shape as the registration `brand`.' }
campaign: { type: object, description: 'Same shape as the registration `campaign`.' }
messagingBrandName: { type: string, minLength: 2, maxLength: 60 }
responses:
'200':
description: Composed payloads + findings.
content:
application/json:
schema:
type: object
properties:
composed:
type: object
description: 'The exact payloads a submission would store (post-branding, disclosures appended, auto-replies generated).'
properties:
brand: { type: object }
campaign: { type: object }
advisories:
type: array
items:
type: object
properties:
field: { type: [string, "null"], description: 'The payload field the finding is about, when attributable.' }
code: { type: [string, "null"], description: 'Stable rule id for deterministic findings; absent on AI findings.' }
concern: { type: string }
severity: { type: string, enum: [block, warn] }
verdict: { type: string, enum: [pass, warn, fail, unreviewed] }
aiUnavailable: { type: boolean, description: 'True when the AI portion of the check could not run; advisories then contain only deterministic findings.' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sms/registrations/{id}:
delete:
x-resource-group: "telephony"
operationId: deactivateSmsRegistration
tags: [SMS]
summary: Deactivate a brand/campaign registration
description: |
Terminates the campaign with the carrier registry so the recurring
monthly campaign fee stops (carriers bill the first 3 months of a
campaign regardless). Numbers covered by it can no longer SEND texts —
receiving is unaffected — until they're registered under a new brand.
Irreversible: a deactivated campaign cannot be restored; texting again
later requires a new registration (new one-time and review fees).
Idempotent.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Registration deactivated.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [deactivated] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
get:
x-resource-group: "telephony"
operationId: getSmsRegistration
tags: [SMS]
summary: Get a carrier registration
description: Poll this for approval progress after starting a registration.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Registration
content:
application/json:
schema:
type: object
properties:
id: { type: string }
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
status: { type: string, enum: [pending, approved, rejected, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
brandStatus: { type: string }
campaignStatus: { type: string }
declineReason: { type: [string, "null"] }
phoneNumbers: { type: array, items: { type: string } }
awaitingOtp: { type: boolean }
campaignContent:
type: object
description: |
The submitted campaign content, present only for rejected
registrations with a campaign. Edit and resubmit it via the
appeal endpoint's optional content fields.
properties:
messageFlow: { type: string }
sample1: { type: string }
sample2: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/verify-otp:
post:
x-resource-group: "telephony"
operationId: verifySmsRegistrationOtp
tags: [SMS]
summary: Submit the sole-prop OTP
description: |
Completes sole-proprietor 10DLC brand verification by submitting the
one-time PIN texted to the brand's mobile number. On success the
registration continues to campaign creation automatically.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [otpPin]
properties:
otpPin: { type: string, minLength: 4, maxLength: 10 }
responses:
'200':
description: OTP result
content:
application/json:
schema:
type: object
properties:
verified: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/resend-otp:
post:
x-resource-group: "telephony"
operationId: resendSmsRegistrationOtp
tags: [SMS]
summary: Re-send the sole-prop OTP
description: |
Re-sends the sole-proprietor verification PIN to the brand's mobile
number — use it when the original code expired or never arrived. Only
valid while the registration is pending and awaiting its OTP; rate
limited to one send per minute.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: A new code was sent
content:
application/json:
schema:
type: object
properties:
sent: { type: boolean }
'400': { description: 'Malformed `id`, or the registration is not awaiting a verification code.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'429': { description: A code was just sent — wait a minute before requesting another }
/v1/sms/registrations/{id}/appeal:
post:
x-resource-group: "telephony"
operationId: appealSmsRegistration
tags: [SMS]
summary: Appeal a rejected campaign
description: |
Appeals a rejected 10DLC campaign with the carrier registry. Only a
registration that reached campaign creation can be appealed; a
brand-level rejection should be fixed and re-verified instead. On
success the registration returns to `pending`.
Content rejections (e.g. an opt-in flow without a verifiable form link,
or unrealistic samples) should be FIXED in the same call: pass the
corrected `messageFlow` / `sample1` / `sample2` and the campaign is
updated before the appeal is filed, so the reviewer sees the new
content. The current content is on `GET /v1/sms/registrations/{id}`
(`campaignContent`).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [appealReason]
properties:
appealReason: { type: string, minLength: 10, maxLength: 2000, description: Goes verbatim to the carrier reviewer — address the decline reason directly. }
messageFlow: { type: string, minLength: 40, maxLength: 2048, description: Corrected opt-in flow; include a link to the opt-in page/form. }
sample1: { type: string, minLength: 20 }
sample2: { type: string, minLength: 20 }
responses:
'200':
description: Appeal submitted; the registration is pending again.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [pending] }
'400': { description: 'Malformed `id`, or the registration has no campaign to appeal (fix the brand and re-verify instead).' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/respond:
post:
x-resource-group: "telephony"
operationId: respondToSmsRegistrationReview
tags: [SMS]
summary: Reply to a change request
description: |
Replies to a reviewer change request on a registration in
`changes_requested` state: a note, hosted document URLs (from
`POST /v1/sms/opt-in-proof`), or both, sent together. The registration
returns to `requested` (back in review) — no need to resubmit the
whole registration. To change the submitted brand/campaign fields
themselves, resubmit via `POST /v1/sms/registrations` with
`resubmitRequestId` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
note: { type: string, maxLength: 2000, description: 'Answer for the reviewer. Required when no files are sent.' }
files:
type: array
maxItems: 5
items: { type: string }
description: 'Hosted document URLs returned by POST /v1/sms/opt-in-proof.'
responses:
'200':
description: Reply recorded; the registration is back in review.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [requested] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'409': { description: Registration is not waiting on changes }
/v1/sms/opt-in-proof:
post:
x-resource-group: "contacts"
operationId: uploadSmsOptInProofFile
tags: [SMS]
summary: Upload opt-in form proof
description: |
Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
public URL. Include that URL in the campaign's `messageFlow` (the
opt-in workflow text) — the carrier registry has no attachment field,
so reviewers verify consent by opening links in that answer. Works
before a registration exists (use it when registering) and for
appeals. `/v1/sms/registrations/{id}/opt-in-proof` is an alias.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
responses:
'200':
description: File hosted.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Public URL to reference in the opt-in flow text. }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: Unsupported file type or file too large }
/v1/sms/registrations/{id}/opt-in-proof:
post:
x-resource-group: "contacts"
operationId: uploadSmsOptInProof
tags: [SMS]
summary: Upload opt-in form proof for an appeal
description: |
Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
public URL. Carrier reviewers reject campaigns whose consent can't be
verified and ask for a "link/screenshot of the opt-in form" — the
registry has no attachment field, so include the returned URL inside
the `messageFlow` you submit with the appeal
(`POST /v1/sms/registrations/{id}/appeal`).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
responses:
'200':
description: File hosted.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Public URL to reference in the opt-in flow text. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'422': { description: Unsupported file type or file too large }
/v1/sms/registrations/share:
post:
x-resource-group: "telephony"
operationId: shareSmsRegistration
tags: [SMS]
summary: Create a registration share link
description: |
Creates a single-use, expiring link (valid 7 days) that lets someone
else (whoever has the legal business details) fill in the carrier
registration form for one of your numbers, without a Zernio login. The
registration is created under your account once the form is submitted.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [numberId]
properties:
numberId: { type: string, description: "Your phone number's ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: Share link created.
content:
application/json:
schema:
type: object
properties:
url: { type: string }
expiresAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/whatsapp/template-library:
get:
x-resource-group: "accounts"
operationId: getWhatsAppLibraryTemplate
tags: [WhatsApp Templates]
summary: Look up a library template
description: |
Look up a single pre-approved Template Library template by its exact name, to
introspect its structure before importing it. Most importantly it returns the
template's `buttons`: a library template with `URL` / `PHONE_NUMBER` buttons
must be created with a matching `library_template_button_inputs` array (see
Create Template), or Meta rejects it. Use this to discover which inputs to collect.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: name, in: query, required: true, schema: { type: string }, description: Exact library template name }
- { 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 social account ID
schema:
type: string
responses:
'200':
description: Business profile retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
businessProfile:
type: object
properties:
about: { type: string, description: Short description (max 139 chars) }
address: { type: string }
description: { type: string, description: Full description (max 512 chars) }
email: { type: string }
profilePictureUrl: { type: string, format: uri }
websites:
type: array
items: { type: string }
maxItems: 2
vertical: { type: string, description: Business category }
'400': { description: accountId is required or phone number ID not found }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: updateWhatsAppBusinessProfile
tags: [WhatsApp]
summary: Update business profile
description: |
Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated.
Constraints: about max 139 chars, description max 512 chars, max 2 websites.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
properties:
accountId:
type: string
description: WhatsApp social account ID
about:
type: string
maxLength: 139
description: Short business description (max 139 characters)
address:
type: string
description: Business address
description:
type: string
maxLength: 512
description: Full business description (max 512 characters)
email:
type: string
format: email
description: Business email
websites:
type: array
maxItems: 2
items: { type: string, format: uri }
description: Business websites (max 2)
vertical:
type: string
description: Business category (e.g., RETAIL, ENTERTAINMENT, etc.)
profilePictureHandle:
type: string
description: Handle from resumable upload for profile picture
example:
accountId: "507f1f77bcf86cd799439011"
about: "We help businesses grow"
description: "Premium business solutions for startups and enterprises"
email: "hello@example.com"
websites: ["https://example.com"]
responses:
'200':
description: Business profile updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Business profile updated successfully"
'400': { description: "Validation error (field too long, too many websites, etc.)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/photo:
post:
x-resource-group: "accounts"
operationId: uploadWhatsAppProfilePhoto
tags: [WhatsApp]
summary: Upload profile picture
description: |
Upload a new profile picture for the WhatsApp Business Profile.
Uses Meta's resumable upload API under the hood: creates an upload session,
uploads the image bytes, then updates the business profile with the resulting handle.
Provide the image either as a binary upload (`multipart/form-data` with `file`)
or as a download URL (`application/json` with `url`) — with a URL we fetch the
image server-side and upload the bytes for you. Meta's profile-photo API is
bytes-only, so there is no direct URL passthrough. JPEG/PNG, max 5MB either way.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [accountId, file]
properties:
accountId:
type: string
description: WhatsApp social account ID
file:
type: string
format: binary
description: Image file (JPEG or PNG, max 5MB, recommended 640x640)
application/json:
schema:
type: object
required: [accountId, url]
properties:
accountId:
type: string
description: WhatsApp social account ID
url:
type: string
format: uri
description: Publicly reachable https URL of the image (JPEG or PNG, max 5MB, recommended 640x640). Fetched server-side; must resolve directly without redirects.
responses:
'200':
description: Profile picture updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { description: "Invalid file type/URL, file too large, or missing parameters" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
'422': { description: "Profile photo is locked for WhatsApp coexistence numbers (manage it in the WhatsApp Business app)" }
/v1/whatsapp/business-profile/display-name:
get:
x-resource-group: "accounts"
operationId: getWhatsAppDisplayName
tags: [WhatsApp]
summary: Get display name status
description: |
Fetch the current display name and its Meta review status for a WhatsApp Business account.
Display name changes require Meta approval and can take 1-3 business days.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Display name info retrieved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
displayName:
type: object
properties:
name: { type: string, description: Current verified display name }
status:
type: string
enum: [APPROVED, PENDING_REVIEW, DECLINED, NONE]
description: Meta review status for the display name
phoneNumber: { type: string, description: Display phone number }
'400': { description: accountId is required }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found or accountId is not a valid ObjectId }
post:
x-resource-group: "accounts"
operationId: updateWhatsAppDisplayName
tags: [WhatsApp]
summary: Request display name change
description: |
Submit a display name change request for the WhatsApp Business account.
The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business).
Changes require Meta review and approval, which typically takes 1-3 business days.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, displayName]
properties:
accountId:
type: string
description: WhatsApp social account ID
displayName:
type: string
minLength: 3
maxLength: 512
description: New display name (must follow WhatsApp naming guidelines)
example:
accountId: "507f1f77bcf86cd799439011"
displayName: "My Business Name"
responses:
'200':
description: Display name change submitted for review
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
displayName:
type: object
properties:
name: { type: string }
status: { type: string, enum: [PENDING_REVIEW] }
'400': { description: "Invalid display name (too short, too long, or missing)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/username:
get:
x-resource-group: "accounts"
operationId: getWhatsappBusinessUsername
tags: [WhatsApp]
summary: Get business username
description: |
Fetch the current WhatsApp Business username and its approval status.
Username status can be `approved` (active), `reserved` (pending activation), or `none` (no username set).
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Business username retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
username:
type: [string, "null"]
description: The current username, or null if none is set
status:
type: string
enum: [approved, reserved, none]
description: Approval state of the username
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: setWhatsappBusinessUsername
tags: [WhatsApp]
summary: Set business username
description: |
Claim or transfer a WhatsApp Business username for the account.
Username rules: 3-35 characters, letters/digits/period/underscore only, must contain at least one letter,
no leading or trailing periods, no consecutive periods, no `www` prefix, no domain TLD suffix (e.g. `.com`).
If the desired username is currently held by another account, pass `transferAction: "force_transfer"` to
request a transfer. On failure the API returns a standard error envelope with one of these codes:
`whatsapp_username_unavailable` (already taken and transfer not requested),
`whatsapp_username_ineligible` (account not eligible to claim a username), or
`whatsapp_username_transfer_required` (username is held elsewhere; retry with `force_transfer`).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, username]
properties:
accountId:
type: string
description: WhatsApp social account ID
username:
type: string
minLength: 3
maxLength: 35
description: >
Desired username. Letters, digits, period, and underscore only.
Must contain at least one letter. No leading, trailing, or consecutive periods.
No www prefix. No domain TLD suffix.
transferAction:
type: string
enum: [none, force_transfer]
default: none
description: Pass `force_transfer` to request a transfer if the username is held by another account
example:
accountId: "507f1f77bcf86cd799439011"
username: "mybusiness"
transferAction: "none"
responses:
'200':
description: Username claimed successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
username: { type: string }
status:
type: string
enum: [approved, reserved, none]
example:
success: true
username: "mybusiness"
status: "approved"
'400': { description: "Validation error or username unavailable (see error code in response)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
delete:
x-resource-group: "accounts"
operationId: deleteWhatsappBusinessUsername
tags: [WhatsApp]
summary: Delete business username
description: |
Release the currently claimed WhatsApp Business username from the account.
After deletion the username becomes available for other accounts to claim.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: WhatsApp social account ID
example:
accountId: "507f1f77bcf86cd799439011"
responses:
'200':
description: Username deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/username/suggestions:
get:
x-resource-group: "accounts"
operationId: getWhatsappBusinessUsernameSuggestions
tags: [WhatsApp]
summary: Get username suggestions
description: |
Retrieve a list of available WhatsApp Business username suggestions based on the account's
business profile name. Use these to help users discover valid, unclaimed usernames.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Username suggestions retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
suggestions:
type: array
items: { type: string }
description: List of available username suggestions
example:
success: true
suggestions: ["mybusiness", "mybusiness_official", "mybiz"]
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/number-info:
get:
x-resource-group: "contacts"
operationId: getWhatsAppNumberInfo
tags: [WhatsApp Phone Numbers]
summary: Get number status
description: |
Live snapshot of a connected number straight from Meta: the phone-number node
(display number, display name + approval, quality rating, messaging-limit tier,
throughput, official-business badge, connection status, health_status) and its
owning WhatsApp Business Account (name, business verification, timezone,
health_status). Fetched live because Meta updates quality/tier/name/health over
time; the call also refreshes the cached values shown on the connection card.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Number + WABA status
content:
application/json:
schema:
type: object
properties:
phone:
type: object
properties:
display_phone_number: { type: string }
verified_name: { type: string }
name_status: { type: string, description: "APPROVED, AVAILABLE_WITHOUT_REVIEW, PENDING_REVIEW, DECLINED, EXPIRED, NONE" }
quality_rating: { type: string, description: "GREEN, YELLOW, RED, UNKNOWN" }
messaging_limit_tier: { type: string, description: "e.g. TIER_250, TIER_1K, TIER_UNLIMITED" }
throughput: { type: object, properties: { level: { type: string, description: "STANDARD or HIGH" } } }
status: { type: string, description: "e.g. CONNECTED" }
is_official_business_account: { type: boolean }
platform_type: { type: string, description: "e.g. CLOUD_API" }
health_status: { type: object, description: Meta's can_send_message health object (messaging + calling signals) }
waba:
type: [object, "null"]
properties:
name: { type: string }
business_verification_status: { type: string, description: "verified, not_verified, pending, ..." }
timezone_id: { type: string, description: Meta integer timezone-enum id }
health_status: { type: object }
'400': { description: Phone number ID not found on account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/block-users/status:
get:
x-resource-group: "contacts"
operationId: getWhatsAppBlockStatus
tags: [WhatsApp]
summary: Check if a user is blocked
description: |
Definitive blocked-state lookup for a single contact. Meta exposes no
membership endpoint, so this reads Zernio's blocklist mirror (kept in
sync by the block/unblock endpoints; the first call per account
backfills the mirror from Meta's full list). Constant-time regardless
of blocklist size.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: user, in: query, required: true, schema: { type: string }, description: Consumer wa_id or E.164 phone (leading + optional) }
responses:
'200':
description: Blocked state
content:
application/json:
schema:
type: object
properties:
blocked: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/block-users:
get:
x-resource-group: "contacts"
operationId: getWhatsAppBlockedUsers
tags: [WhatsApp]
summary: List blocked users
description: |
List the WhatsApp users blocked on this number. Cursor-paginated; pass
`nextCursor` back as `after` to fetch the next page. The blocklist holds
up to 64,000 users.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 }, description: Page size. }
- { name: after, in: query, required: false, schema: { type: string }, description: Cursor from a previous response's `nextCursor`. }
responses:
'200':
description: Blocked users
content:
application/json:
schema:
type: object
properties:
blockedUsers:
type: array
items:
type: object
properties:
waId: { type: string, description: "WhatsApp user ID (usually the phone number without `+`)." }
nextCursor: { type: [string, "null"], description: Pass as `after` to fetch the next page. Null when there are no more pages. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "contacts"
operationId: blockWhatsAppUsers
tags: [WhatsApp]
summary: Block users
description: |
Block one or more WhatsApp users on this number. Blocked users cannot
message your number or see that you are online, and your sends to them
return an error.
Meta constraints, surfaced per-user in `failed` (the request itself still
succeeds for the rest of the batch):
- Only users who messaged your business within the last 24 hours can be
blocked (failures outside the window report "Re-engagement required").
- Up to 1,000 users per request; the blocklist caps at 64,000.
- Other WhatsApp Business accounts cannot be blocked.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, users]
properties:
accountId: { type: string, description: WhatsApp social account ID }
users:
type: array
minItems: 1
maxItems: 1000
items: { type: string }
description: Phone numbers (E.164, e.g. "+16505551234") or WhatsApp user IDs to block.
responses:
'200':
description: Per-user results
content:
application/json:
schema:
type: object
properties:
blocked:
type: array
description: Users successfully blocked.
items:
type: object
properties:
input: { type: string, description: The value you sent. }
waId: { type: string, description: Resolved WhatsApp user ID. }
failed:
type: array
description: Users that could not be blocked, with reasons.
items:
type: object
properties:
input: { type: string }
errors: { type: array, items: { type: string } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
delete:
x-resource-group: "contacts"
operationId: unblockWhatsAppUsers
tags: [WhatsApp]
summary: Unblock users
description: |
Unblock one or more previously blocked WhatsApp users on this number.
Up to 1,000 users per request; per-user failures are reported in
`failed` without failing the rest of the batch.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, users]
properties:
accountId: { type: string, description: WhatsApp social account ID }
users:
type: array
minItems: 1
maxItems: 1000
items: { type: string }
description: Phone numbers (E.164) or WhatsApp user IDs to unblock.
responses:
'200':
description: Per-user results
content:
application/json:
schema:
type: object
properties:
unblocked:
type: array
description: Users successfully unblocked.
items:
type: object
properties:
input: { type: string, description: The value you sent. }
waId: { type: string, description: Resolved WhatsApp user ID. }
failed:
type: array
description: Users that could not be unblocked, with reasons.
items:
type: object
properties:
input: { type: string }
errors: { type: array, items: { type: string } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/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 social 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 social 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 social account ID }
responses:
'200':
description: Dataset lookup
content:
application/json:
schema:
type: object
properties:
datasetId:
type: [string, "null"]
description: Meta dataset ID linked to the WABA, or null if not provisioned yet
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: createWhatsAppDataset
tags: [WhatsApp]
summary: Provision CTWA dataset
description: |
Creates (or fetches, if one already exists) the Meta dataset that
Click-to-WhatsApp ad events are reported against via the Conversions
API, and persists its ID on the account as `metadata.metaCapiDatasetId`.
The call is GET-first idempotent — a WABA can only own one CTWA
dataset, so a second call after a successful provision is a safe no-op
that returns the same ID with `created: false`.
Requires the connected WhatsApp account's token to carry the
`whatsapp_business_manage_events` permission. If the permission is
missing the endpoint returns 422 with a message asking the user to
reconnect the account.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: WhatsApp social account ID
responses:
'200':
description: Dataset provisioned (or already present)
content:
application/json:
schema:
type: object
properties:
datasetId:
type: string
description: Meta dataset ID linked to the WABA
created:
type: boolean
description: True if Meta created a new dataset on this call; false if one already existed
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
'422': { description: Account is missing `whatsapp_business_manage_events` — reconnect required }
'502': { description: Upstream Meta failure during provisioning }
# ──────────────────────────────────────────────────────────────────────────
# PHONE NUMBERS
# ──────────────────────────────────────────────────────────────────────────
# ─── Phone Numbers (platform-independent) ──────────────────────
#
# A phone number is the unit you buy; WhatsApp, SMS, and Voice are features
# enabled on it. This is the canonical namespace; the
# /v1/whatsapp/phone-numbers/* paths further down are deprecated aliases
# kept for back-compat.
/v1/phone-numbers:
get:
x-resource-group: "telephony"
operationId: listPhoneNumbers
tags: [Phone Numbers]
summary: List phone numbers
description: |
List all phone numbers purchased by the authenticated user.
By default, released numbers are excluded. Connected (bring-your-own)
WhatsApp numbers are returned in the separate `connected` array; they
are not billed and have no provisioning lifecycle.
security:
- bearerAuth: []
parameters:
- name: status
in: query
required: false
description: |
Filter by status (by default excludes released numbers). NOTE:
`status=pending_regulatory` returns the "provisioning" view — numbers
still in review PLUS recently-declined (last 30 days) ones, so a
failed registration surfaces (with `regulatoryDeclineReason`) instead
of silently disappearing. Declined numbers can be re-submitted via
POST /v1/phone-numbers/{id}/remediate. `verifying` is the
short-lived state after the number is provisioned on our side while
WhatsApp confirms the activation code; the number is not billed until
it reaches `active`.
schema:
type: string
enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, active, suspended, releasing, released]
- name: profileId
in: query
required: false
description: Filter by profile
schema:
type: string
responses:
'200':
description: Phone numbers retrieved successfully
content:
application/json:
schema:
type: object
properties:
numbers:
type: array
items:
type: object
properties:
_id: { type: string }
phoneNumber: { type: string }
country: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person) — set from the submitted KYC." }
telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
monthlyCents: { type: integer, description: Per-country monthly price in cents ($2..$25). }
hostedByZernio: { type: boolean, description: "False for numbers you brought yourself (connected via Meta embedded signup) — they live on your own carrier, so SMS/Calls can't be enabled on them." }
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 social account
2. Decrement the Stripe subscription quantity (or cancel if last number)
3. Release the number from Telnyx
4. Mark the number as released
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number released successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, description: "\"released\"" }
releasedAt: { type: string, format: date-time }
'400': { description: Phone number is already released or being released }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'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: you do not pick a specific number, the system provisions one and
auto-assigns it. With usage-based billing active and a payment method on file, the
number provisions inline and bills per month on your usage-based invoice (there is
no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.
Requires usage-based billing (the Usage plan). The maximum number of phone numbers
is determined by the user's plan.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- profileId
properties:
profileId:
type: string
description: >
Preferred profile for the number. One number = one
profile, so when the requested profile already holds a
number the API assigns the next free profile instead (or
creates one) and returns the actual assignment in
`profileId` on the response.
country:
type: string
description: >
ISO 3166-1 alpha-2 country for the number (default US).
International numbers require usage-based billing. Tier 3/4
countries return 202 { status: "kyc_required", kycUrl } —
the customer must complete KYC at that URL before the number
is ordered. See GET /v1/phone-numbers/countries.
default: US
numberType:
type: string
enum: [local, mobile, national, toll_free]
description: >
Which of the country's offered number types to order
(see `types[]` on GET /v1/phone-numbers/countries). Omitted
= the country's default type, which is always the
WhatsApp-safe choice. Capabilities, price, and KYC
requirements are per (country, type): toll_free can never
connect WhatsApp (400 when combined with
connectWhatsapp:true), and wantsSms:true requires an
SMS-capable type.
areaCode:
type: string
pattern: '^\d{1,4}$'
description: >
Area code (national destination code, e.g. 11 for Sao
Paulo) the number must be in. Hard constraint: when the
area has no deliverable inventory the purchase fails with
409 code AREA_CODE_UNAVAILABLE instead of assigning a
number from another area, and later replacements stay in
this area too. Omit for any area. Get live options from
GET /v1/phone-numbers/availability (areaOptions).
connectWhatsapp:
type: boolean
default: true
description: >
A phone number is the unit; WhatsApp is one optional feature.
Pass false to buy a STANDALONE number (Calls/SMS only):
provisioning skips the Meta pre-verify/OTP steps and the
number activates immediately. Omitted defaults to the
WhatsApp provisioning path. WhatsApp can be connected to a
standalone number later from the connect flow.
wantsSms:
type: boolean
default: false
description: >
SMS capability is per-number, not per-country. Pass true to
provision from the SMS-capable inventory pool so the number
can actually text (see also GET /v1/phone-numbers/available
with sms=true, and smsAvailable on GET
/v1/phone-numbers/countries).
wantsWhatsapp:
type: boolean
default: false
description: >
Declare WhatsApp intent on a STANDALONE purchase
(connectWhatsapp:false). The number still activates and
bills immediately, but if WhatsApp's buy-time check rejects
the assigned number, it is automatically swapped for a
WhatsApp-eligible one during the purchase instead of being
delivered with WhatsApp unavailable. Ignored on the WhatsApp
provisioning path (connectWhatsapp omitted or true), which
always delivers a WhatsApp-verified number.
purchaseIntentId:
type: string
maxLength: 100
description: >
Optional idempotency key. Send the same value when retrying
a purchase: if a number was already bought under this key,
the API returns { status: "already_purchased", numberId,
phoneNumber, profileId } instead of provisioning a second
number.
Generate a fresh key for each genuinely new purchase.
allowMultiple:
type: boolean
default: false
description: >
Any second purchase within 10 minutes of a previous one is
rejected with 409 code PURCHASE_VELOCITY as duplicate
protection. Pass true to confirm the additional purchase is
intentional (e.g. bulk provisioning).
example:
profileId: "507f1f77bcf86cd799439011"
country: "DE"
responses:
'200':
description: |
Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).
content:
application/json:
schema:
oneOf:
- type: object
description: Checkout session created (first number)
properties:
message: { type: string }
checkoutUrl: { type: string, format: uri }
- type: object
description: Phone number provisioned inline (subsequent numbers)
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string }
country: { type: string }
provisionedAt: { type: string, format: date-time }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
profileId: { type: string, description: 'The profile the number was actually assigned to.' }
- type: object
description: >
A number was already purchased under the supplied
purchaseIntentId; no new number was provisioned.
properties:
status: { type: string, enum: [already_purchased] }
numberId: { type: string }
phoneNumber: { type: string }
profileId: { type: string, description: 'The profile the number was actually assigned to.' }
'400': { description: "Plan limit reached, profileId required, or country not available" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: A paid plan is required }
'409':
description: >
Either duplicate-purchase protection (code PURCHASE_VELOCITY:
another number was purchased within the last 10 minutes; retry
with allowMultiple: true to confirm), or the requested areaCode
has no deliverable inventory right now (code
AREA_CODE_UNAVAILABLE: pick another area or omit areaCode).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE] }
'202':
description: Country requires end-user KYC before the number can be ordered.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [kyc_required] }
country: { type: string }
numberType: { type: string, description: The type that will be ordered after KYC approval. }
kycUrl: { type: string }
'402': { description: 'Payment method required (Metronome user with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
'422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }
/v1/phone-numbers/countries:
get:
x-resource-group: "telephony"
operationId: listPhoneNumberCountries
tags: [Phone Numbers]
summary: List offerable number countries
description: |
The phone number countries available to purchase, each with its flat
monthly price (cents), regulatory tier, whether it needs end-user KYC
(Tier 3/4), and per-feature availability (PSTN calls, WhatsApp, SMS,
and WhatsApp Business Calling outbound). Drives the country picker.
Tier-4 countries appear only when enabled.
security:
- bearerAuth: []
responses:
'200':
description: Offerable countries, cheapest first.
content:
application/json:
schema:
type: object
properties:
countries:
type: array
items:
type: object
properties:
code: { type: string, description: ISO 3166-1 alpha-2 }
tier: { type: integer, enum: [1, 2, 3, 4] }
monthlyCents: { type: integer }
needsKyc: { type: boolean }
callsAvailable: { type: boolean, description: "Regular phone (PSTN) calling on the number, inbound + outbound. Available on every offerable country." }
whatsappAvailable: { type: boolean, description: WhatsApp can be enabled on numbers from this country. }
smsAvailable: { type: boolean, description: Whether this country's number type can do SMS. Use it to filter the picker when the buyer wants SMS (pair with `wantsSms` on purchase). }
outboundCallingAvailable: { type: boolean, description: "WhatsApp Business Calling (BIC) outbound availability, a Meta feature blocked in some countries. NOT the PSTN Calls feature (`callsAvailable`)." }
inStock: { type: boolean, description: "Live carrier-stock snapshot (refreshed every 6h + on availability checks): false when NO offered type currently has deliverable inventory, so a purchase would fail. Treat as advisory; the purchase itself re-checks." }
types:
type: array
description: >
Every number type offered in this country (default
first). Capabilities, KYC tier, monthly price, and
stock are per type. The country-level fields above
mirror the first (default) entry. Pass the chosen
`numberType` to POST /v1/phone-numbers/purchase.
items:
type: object
properties:
numberType: { type: string, enum: [local, mobile, national, toll_free] }
tier: { type: integer, enum: [1, 2, 3, 4] }
needsKyc: { type: boolean }
monthlyCents: { type: integer }
whatsappAvailable: { type: boolean, description: "Always false for toll_free (WhatsApp does not reliably register toll-free numbers)." }
smsAvailable: { type: boolean }
callsAvailable: { type: boolean }
inStock: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/available:
get:
x-resource-group: "telephony"
operationId: searchAvailablePhoneNumbers
tags: [Phone Numbers]
summary: Search available numbers
description: |
Search the provider's inventory for numbers available to purchase in a
country (default US). Optional filters narrow the results. The country
must be offerable (see GET /v1/phone-numbers/countries). Voice
capability is always required; pass `sms=true` to only see numbers that
can also text (SMS support is per-number, not per-country).
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: false, schema: { type: string, default: US } }
- { name: type, in: query, required: false, description: "Number type; defaults to the country's WhatsApp-safe type", schema: { type: string } }
- { name: prefix, in: query, required: false, description: Area code, schema: { type: string } }
- { name: locality, in: query, required: false, description: City, schema: { type: string } }
- { name: contains, in: query, required: false, description: Pattern to match within the number, schema: { type: string } }
- { name: sms, in: query, required: false, schema: { type: boolean }, description: "true narrows the pool to SMS-capable numbers. Each result still carries its full `features` list for per-number capability badging." }
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
responses:
'200':
description: Available numbers.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
requireSms: { type: boolean, description: Echo of the `sms` filter applied to this search. }
numbers:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
features: { type: array, items: { type: string }, description: "Provider capability list for this number (e.g. voice, sms, mms)." }
'400': { description: Country not available }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/availability:
get:
x-resource-group: "telephony"
operationId: checkPhoneNumberAvailability
tags: [Phone Numbers]
summary: Check country availability
description: |
Pre-purchase check, so you can warn BEFORE a customer invests in KYC
(regulated review is async, 1-3 days). Tells you whether we have
deliverable inventory, and what address the customer needs:
- `addressConstraint: geo` → the registered address MUST be in one of
the returned `areas` (the only place we have stock). A different-area
address passes pre-approval but the number can never be assigned.
- `addressConstraint: country` → any in-country address works.
- `addressConstraint: none` → field-only / instant country, no address.
Call this before starting the KYC form for regulated countries.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
- { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
responses:
'200':
description: Availability + address constraint.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
addressConstraint: { type: string, enum: [geo, country, none] }
areas:
type: array
description: For `geo` only — the area(s) the registered address must be in.
items: { type: string }
areaOptions:
type: array
description: >
Live inventory grouped by area code. For US and CA this
is the full country inventory (every area code with
stock, recognizable metros listed first, then
alphabetical); other countries are ordered largest
stock first;
they list the areas in the latest inventory page
(up to 500 numbers, which for most countries is the
entire pool). Empty when out of stock (or the area
lookup failed). Pass a chosen `ndc` as `areaCode` on POST
/v1/phone-numbers/purchase (or on the KYC submit for
regulated countries) to require that area.
items:
type: object
properties:
ndc: { type: string, description: 'Area code (national destination code), e.g. "11".' }
name: { type: string, description: 'Area name: "City, ST" for US/CA (e.g. "Miami, FL"), city otherwise (e.g. "Sao Paulo").' }
count: { type: integer, description: 'Numbers available in this area: country-wide count for US/CA, numbers seen on the latest inventory page otherwise.' }
'400': { description: 'Country not offerable, or the inventory provider rejected the lookup (its 4xx status is forwarded as-is).' }
'401': { $ref: '#/components/responses/Unauthorized' }
'502': { description: 'The inventory provider was unreachable or returned an unclassified error.' }
/v1/whatsapp/phone-numbers:
get:
x-resource-group: "telephony"
operationId: getWhatsAppPhoneNumbers
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: List phone numbers
description: |
Deprecated alias of `/v1/phone-numbers`; same contract. New
integrations should use that path.
List all WhatsApp phone numbers purchased by the authenticated user.
By default, released numbers are excluded. Connected (bring-your-own)
numbers are returned in the separate `connected` array — they are not
billed and have no provisioning lifecycle.
security:
- bearerAuth: []
parameters:
- name: status
in: query
required: false
description: |
Filter by status (by default excludes released numbers). NOTE:
`status=pending_regulatory` returns the "provisioning" view — numbers
still in review PLUS recently-declined (last 30 days) ones, so a
failed registration surfaces (with `regulatoryDeclineReason`) instead
of silently disappearing. Declined numbers can be re-submitted via
POST /v1/whatsapp/phone-numbers/{id}/remediate. `verifying` is the
short-lived state after the number is provisioned on our side while
WhatsApp confirms the activation code; the number is not billed until
it reaches `active`.
schema:
type: string
enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, active, suspended, releasing, released]
- name: profileId
in: query
required: false
description: Filter by profile
schema:
type: string
responses:
'200':
description: Phone numbers retrieved successfully
content:
application/json:
schema:
type: object
properties:
numbers:
type: array
items:
type: object
properties:
_id: { type: string }
phoneNumber: { type: string }
country: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person) — set from the submitted KYC." }
telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
monthlyCents: { type: integer, description: Per-country monthly price in cents ($2..$25). }
hostedByZernio: { type: boolean, description: "False for numbers you brought yourself (connected via Meta embedded signup) — they live on your own carrier, so SMS/Calls can't be enabled on them." }
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: you do not pick a specific number, the system provisions one and
auto-assigns it. With usage-based billing active and a payment method on file, the
number provisions inline and bills per month on your usage-based invoice (there is
no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.
Requires usage-based billing (the Usage plan). The maximum number of phone numbers
is determined by the user's plan.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- profileId
properties:
profileId:
type: string
description: Profile to associate the number with
country:
type: string
description: >
ISO 3166-1 alpha-2 country for the number (default US).
International numbers require usage-based billing. Tier 3/4
countries return 202 { status: "kyc_required", kycUrl } —
the customer must complete KYC at that URL before the number
is ordered. See GET /v1/whatsapp/phone-numbers/countries.
default: US
purchaseIntentId:
type: string
maxLength: 100
description: >
Optional idempotency key. Send the same value when retrying
a purchase: if a number was already bought under this key,
the API returns { status: "already_purchased", numberId,
phoneNumber } instead of provisioning a second number.
Generate a fresh key for each genuinely new purchase.
allowMultiple:
type: boolean
default: false
description: >
Any second purchase within 10 minutes of a previous one is
rejected with 409 code PURCHASE_VELOCITY as duplicate
protection. Pass true to confirm the additional purchase is
intentional (e.g. bulk provisioning).
example:
profileId: "507f1f77bcf86cd799439011"
country: "DE"
responses:
'200':
description: |
Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).
content:
application/json:
schema:
oneOf:
- type: object
description: Checkout session created (first number)
properties:
message: { type: string }
checkoutUrl: { type: string, format: uri }
- type: object
description: Phone number provisioned inline (subsequent numbers)
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string }
country: { type: string }
provisionedAt: { type: string, format: date-time }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
- type: object
description: >
A number was already purchased under the supplied
purchaseIntentId; no new number was provisioned.
properties:
status: { type: string, enum: [already_purchased] }
numberId: { type: string }
phoneNumber: { type: string }
'400': { description: "Plan limit reached, profileId required, or country not available" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: A paid plan is required }
'409':
description: >
Either duplicate-purchase protection (code PURCHASE_VELOCITY:
another number was purchased within the last 10 minutes; retry
with allowMultiple: true to confirm), or the requested areaCode
has no deliverable inventory right now (code
AREA_CODE_UNAVAILABLE: pick another area or omit areaCode).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE] }
'202':
description: Country requires end-user KYC before the number can be ordered.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [kyc_required] }
country: { type: string }
numberType: { type: string, description: The type that will be ordered after KYC approval. }
kycUrl: { type: string }
'402': { description: 'Payment method required (Metronome user with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
'422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }
/v1/whatsapp/phone-numbers/countries:
get:
x-resource-group: "telephony"
operationId: listWhatsAppNumberCountries
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: List offerable number countries
description: |
Deprecated alias of `/v1/phone-numbers/countries`; same contract. New
integrations should use that path.
The WhatsApp number countries available to purchase, each with its flat
monthly price (cents), regulatory tier, whether it needs end-user KYC
(Tier 3/4), and whether outbound calling is available (not BIC-blocked).
Drives the country picker. Tier-4 countries appear only when enabled.
security:
- bearerAuth: []
responses:
'200':
description: Offerable countries, cheapest first.
content:
application/json:
schema:
type: object
properties:
countries:
type: array
items:
type: object
properties:
code: { type: string, description: ISO 3166-1 alpha-2 }
tier: { type: integer, enum: [1, 2, 3, 4] }
monthlyCents: { type: integer }
needsKyc: { type: boolean }
outboundCallingAvailable: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/available:
get:
x-resource-group: "telephony"
operationId: searchAvailableWhatsAppNumbers
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Search available numbers
description: |
Deprecated alias of `/v1/phone-numbers/available`; same contract. New
integrations should use that path.
Search the provider's inventory for numbers available to purchase in a
country (default US). Optional filters narrow the results. The country
must be offerable (see GET /v1/whatsapp/phone-numbers/countries).
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: false, schema: { type: string, default: US } }
- { name: type, in: query, required: false, description: "Number type; defaults to the country's WhatsApp-safe type", schema: { type: string } }
- { name: prefix, in: query, required: false, description: Area code, schema: { type: string } }
- { name: locality, in: query, required: false, description: City, schema: { type: string } }
- { name: contains, in: query, required: false, description: Pattern to match within the number, schema: { type: string } }
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
responses:
'200':
description: Available numbers.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
numbers:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
'400': { description: Country not available }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/availability:
get:
x-resource-group: "telephony"
operationId: checkWhatsAppNumberAvailability
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Check country availability
description: |
Deprecated alias of `/v1/phone-numbers/availability`; same contract. New
integrations should use that path.
Pre-purchase check, so you can warn BEFORE a customer invests in KYC
(regulated review is async, 1-3 days). Tells you whether we have
deliverable inventory, and what address the customer needs:
- `addressConstraint: geo` → the registered address MUST be in one of
the returned `areas` (the only place we have stock). A different-area
address passes pre-approval but the number can never be assigned.
- `addressConstraint: country` → any in-country address works.
- `addressConstraint: none` → field-only / instant country, no address.
Call this before starting the KYC form for regulated countries.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
- { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
responses:
'200':
description: Availability + address constraint.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
addressConstraint: { type: string, enum: [geo, country, none] }
areas:
type: array
description: For `geo` only — the area(s) the registered address must be in.
items: { type: string }
areaOptions:
type: array
description: >
Live inventory grouped by area code. For US and CA this
is the full country inventory (every area code with
stock, recognizable metros listed first, then
alphabetical); other countries are ordered largest
stock first;
they list the areas in the latest inventory page
(up to 500 numbers, which for most countries is the
entire pool). Empty when out of stock (or the area
lookup failed). Pass a chosen `ndc` as `areaCode` on POST
/v1/phone-numbers/purchase (or on the KYC submit for
regulated countries) to require that area.
items:
type: object
properties:
ndc: { type: string, description: 'Area code (national destination code), e.g. "11".' }
name: { type: string, description: 'Area name: "City, ST" for US/CA (e.g. "Miami, FL"), city otherwise (e.g. "Sao Paulo").' }
count: { type: integer, description: 'Numbers available in this area: country-wide count for US/CA, numbers seen on the latest inventory page otherwise.' }
'400': { description: 'Country not offerable, or the inventory provider rejected the lookup (its 4xx status is forwarded as-is).' }
'401': { $ref: '#/components/responses/Unauthorized' }
'502': { description: 'The inventory provider was unreachable or returned an unclassified error.' }
/v1/phone-numbers/kyc:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberKycForm
tags: [Phone Numbers]
summary: Get KYC form spec
description: |
For a Tier 3/4 country, the fields the end customer must provide (Telnyx
regulatory requirements) before a number can be ordered: text, date,
address, or file (document) per requirement.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string } }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Requirements and reuse eligibility are per (country, type). Omitted = the country's default type. Pass the same value on the POST." }
responses:
'200':
description: The KYC form spec.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
example: { type: [string, "null"], description: Concrete example value. }
localTo: { type: [string, "null"], description: ISO country the value must be local to }
reusable:
type: [object, "null"]
description: 'Present when this account already has a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
properties:
available: { type: boolean }
fromPhoneNumber: { type: string }
details:
type: array
description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort — may be empty if the provider lookup fails.
items:
type: object
properties:
label: { type: string }
value: { type: string }
options:
type: array
description: One entry per distinct approved verification, newest first.
items:
type: object
properties:
id: { type: string, description: 'Opaque option id — pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
fromPhoneNumber: { type: string, description: Display only — the number this verification was submitted for. Not a selection key. }
instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
details:
type: array
items:
type: object
properties:
label: { type: string }
value: { type: string }
documentId: { type: string, description: 'Present on document rows — the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
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 — prefer `reuseOptionId`. Omitted = the approved default. No match = 409.' }
areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the number must be in. Hard constraint: an empty area pool fails with 409 code AREA_CODE_UNAVAILABLE instead of ordering from another area. Omit for any area. Options come from GET /v1/phone-numbers/availability (areaOptions); the purchase 202 kycUrl echoes the areaCode picked at purchase time so it can be passed here.' }
endUserFirstName: { type: string, description: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. }
endUserLastName: { type: string, description: End user's legal last name. Same condition as endUserFirstName. }
values:
type: object
additionalProperties: { type: string }
description: requirementId → textual value
documents:
type: array
description: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit).
items:
oneOf:
- type: object
required: [requirementId, filename, base64]
properties:
requirementId: { type: string }
filename: { type: string }
base64: { type: string }
- type: object
required: [requirementId, documentId]
properties:
requirementId: { type: string }
documentId: { type: string, description: Id from POST /v1/phone-numbers/kyc/upload-document. }
address:
type: object
properties:
requirementId: { type: string }
country_code: { type: string }
business_name: { type: string }
first_name: { type: string }
last_name: { type: string }
street_address: { type: string }
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] }
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 — enter values exactly as they appear on the
carrier bill.
required: [entityName, authPersonName, accountNumber, streetAddress, locality, postalCode, countryCode]
properties:
entityName: { type: string, description: "Account holder / business name, as on the carrier account." }
authPersonName: { type: string, description: "Full name (first + last) of the person authorizing the port — must match the LOA signature." }
billingPhoneNumber: { type: string, description: "Phone number on the losing carrier's bill. Defaults to the ported number itself on single-number orders. Validated as a real phone number when present." }
accountNumber: { type: string, description: "Account number with the losing carrier — required (carriers reject ports without it; on prepaid mobile plans it is often the phone number itself)." }
pinPasscode: { type: string, description: "Transfer PIN. Required for US/CA mobile numbers (wireless carriers reject PIN-less ports). Forwarded to the carrier, never stored. International porting codes (e.g. the UK PAC) go through `requirements` instead." }
taxIdentifier: { type: string, maxLength: 50, description: "Company tax id on the carrier account (EU ports, e.g. Spanish CIF)." }
businessIdentifier: { type: string, maxLength: 50, description: "Business registration id on the carrier account (EU ports)." }
streetAddress: { type: string }
extendedAddress: { type: string }
locality: { type: string }
administrativeArea: { type: string, description: "Region. Required for US/CA as the 2-letter state/province code (full names are accepted and normalized); optional elsewhere." }
postalCode: { type: string, description: "Postal code. Validated as a US ZIP / Canadian postal code for US/CA; free-form elsewhere." }
countryCode: { type: string, minLength: 2, maxLength: 2, enum: [US, CA, GB, ES, DE, FR, NL, AU, BR], description: "Service-address country (a supported port-in country)." }
loaDocumentId: { type: string, description: Document id from POST /v1/phone-numbers/port-in/documents (kind=loa). }
invoiceDocumentId: { type: string, description: Document id from POST /v1/phone-numbers/port-in/documents (kind=invoice). }
focDatetimeRequested: { type: string, format: date-time, description: "Requested port date; the carrier confirms the actual FOC later. US/CA default is one week out (shifted off weekends); international orders are scheduled into the carrier's next allowed porting window at or after this date." }
customerReference: { type: string, maxLength: 100 }
portType: { type: string, enum: [full, partial], default: full, description: "Whether the losing account ports all its numbers (full) or keeps some (partial)." }
requirements:
type: array
maxItems: 30
description: >-
Country-specific requirement values for international ports
(from GET /v1/phone-numbers/port-in/requirements). Not
needed for US/CA. The LOA and invoice requirements are
satisfied automatically by loaDocumentId/invoiceDocumentId,
and address-type requirements by the endUser service
address.
items:
type: object
required: [requirementTypeId, fieldValue]
properties:
requirementTypeId: { type: string, description: "The requirement's id, from the requirements endpoint." }
fieldValue: { type: string, maxLength: 1000, description: "Text value, ISO datetime, or a documentId from POST /v1/phone-numbers/port-in/documents, per the requirement's kind." }
responses:
'201':
description: Port submitted. Top-level fields mirror the first successfully submitted order; per-order truth (including failures) is in `orders`.
content:
application/json:
schema:
type: object
properties:
id: { type: string, description: Porting order ID. }
telnyxPortingOrderId: { type: string }
status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
phoneNumbers: { type: array, items: { type: string } }
orders:
type: array
items:
type: object
properties:
id: { type: string }
telnyxPortingOrderId: { type: string }
status: { type: string }
phoneNumbers: { type: array, items: { type: string } }
error: { type: string, description: Present when this split order failed to submit (it stays as a cancellable draft). }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: "A number is already provisioned, or already in an in-flight port" }
'422': { description: "A number is not portable (reason included), numbers span multiple non-US/CA countries, or every split order failed to submit" }
get:
x-resource-group: "telephony"
operationId: listPhoneNumberPortIns
tags: [Phone Numbers]
summary: List port-in orders
description: |
Your porting orders, newest first (max 50). Poll this for port progress:
pending, confirmed FOC date, exception reason, or ported.
security:
- bearerAuth: []
responses:
'200':
description: Porting orders
content:
application/json:
schema:
type: object
properties:
orders:
type: array
items:
type: object
properties:
id: { type: string }
status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
telnyxStatusValue: { type: [string, "null"], description: Raw carrier status string. }
phoneNumbers: { type: array, items: { type: string } }
fastPortEligible: { type: [boolean, "null"] }
focDatetimeRequested: { type: [string, "null"], format: date-time }
focDatetimeActual: { type: [string, "null"], format: date-time }
declineReason: { type: [string, "null"] }
submittedAt: { type: [string, "null"], format: date-time }
portedAt: { type: [string, "null"], format: date-time }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/check:
post:
x-resource-group: "telephony"
operationId: checkPhoneNumberPortability
tags: [Phone Numbers]
summary: Check portability
description: |
Pre-flight portability check: whether each number can be ported in and
whether it qualifies for FastPort, BEFORE the user commits to a port
order (LOA, invoice, service address). Read-only; creates no order and
bills nothing.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
minItems: 1
maxItems: 50
items: { type: string }
description: "E.164 numbers to check, e.g. +13035550000."
responses:
'200':
description: Per-number portability.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
portable: { type: boolean }
fastPortable: { type: boolean, description: Qualifies for the carrier's accelerated FastPort lane. }
lineType: { type: [string, "null"], description: "Line type when known (mobile, landline, voip…). A US/CA mobile number requires the transfer PIN at submit." }
countryCode: { type: [string, "null"], description: "ISO country of the number — pass it to GET /v1/phone-numbers/port-in/requirements for international numbers." }
phoneNumberType: { type: [string, "null"], description: "Carrier number-type classification (local, mobile, national, toll_free…) — the numberType for the requirements endpoint." }
notPortableReason: { type: [string, "null"], description: Carrier reason when not portable; null when portable. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/documents:
post:
x-resource-group: "telephony"
operationId: uploadPhoneNumberPortInDocument
tags: [Phone Numbers]
summary: Upload a porting document
description: |
Upload ONE porting document and get back its `documentId`. For the
signed LOA / carrier invoice the id goes to `loaDocumentId` /
`invoiceDocumentId`; for a country-specific document requirement
(international ports) it becomes that requirement's `fieldValue`.
Requirement documents are normalized to PDF automatically (regulators
reject raw images). PDF, JPEG, or PNG, 10MB max. Uploads must be
attached to an order within 30 minutes or the carrier deletes them.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "The document (PDF/JPEG/PNG, 10MB max)." }
kind: { type: string, description: "'loa', 'invoice', or any short slug for requirement documents. Informational; used for the stored filename." }
responses:
'200':
description: Document uploaded.
content:
application/json:
schema:
type: object
properties:
documentId: { type: string }
'400': { description: "Missing file, file too large, or unsupported type" }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/requirements:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberPortInRequirements
tags: [Phone Numbers]
summary: Country porting requirements
description: |
The country-specific information a port-in needs BEYOND the LOA,
invoice, and account/address details — e.g. an ID copy, proof of
address, a tax id, or a porting code. Call it after the portability
check (which returns each number's `countryCode` and
`phoneNumberType`), render the fields, and pass the collected values as
the create request's `requirements`. US/CA return an empty list.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 2 }, description: "ISO country of the numbers being ported (a supported port-in country)." }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free], default: local }, description: "The portability check's phoneNumberType — requirements differ by type." }
responses:
'200':
description: Requirement fields for the country/type combination.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
supported: { type: boolean, description: "false when the combination includes a step that can't be completed through the API (e.g. an in-person identity verification) — porting it needs support." }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string, description: "Pass back as requirements[].requirementTypeId." }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: "text/date take a string value; file takes a documentId from the documents endpoint; address is satisfied automatically from the end-user service address." }
description: { type: string }
example: { type: string }
acceptableValues: { type: array, items: { type: string }, description: "When present, the value must be one of these." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "Country not supported for port-in" }
/v1/phone-numbers/port-in/{id}/requirements:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberPortInOrderRequirements
tags: [Phone Numbers]
summary: A port-in order's pending requirements
description: |
The live requirements on an EXISTING porting order: which are filled,
which are still pending, and which bounced on review
(`requirement-info-exception`). Use it to fix and resubmit a rejected
international port. Same field shape as the country-level requirements
endpoint, plus per-requirement status.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
responses:
'200':
description: The order's requirements with statuses.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
requirements:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action] }
description: { type: string }
example: { type: string }
acceptableValues: { type: array, items: { type: string } }
status: { type: string, description: "requirement-info-pending | requirement-info-under-review | requirement-info-exception | approved" }
filled: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Porting order not found }
/v1/phone-numbers/port-in/{id}:
delete:
x-resource-group: "telephony"
operationId: cancelPhoneNumberPortIn
tags: [Phone Numbers]
summary: Cancel a port-in
description: |
Cancel an in-flight port (wrong number, staying with the old carrier).
Only orders that haven't ported can be cancelled; a completed port is a
normal number release instead. The carrier may report `cancel-pending`
briefly while the losing carrier acknowledges; it settles to
`cancelled`.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
responses:
'200':
description: Cancel accepted (idempotent when already cancelled).
content:
application/json:
schema:
type: object
properties:
id: { type: string }
status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Porting order not found }
'409': { description: "Port already completed (release the number instead), or the carrier rejected the cancel (reason included)" }
/v1/phone-numbers/kyc/review-packet:
post:
x-resource-group: "telephony"
operationId: reviewPhoneNumberKycPacket
tags: [Phone Numbers]
summary: Pre-review a KYC packet
description: |
Advisory dry-run of a regulated-KYC packet before submitting: reviews
the exact documents the regulator will see (referenced by the ids from
POST /v1/phone-numbers/kyc/upload-document) against the declared values
and address, and returns plain-language advisories for likely decline
reasons (wrong document type, mismatched address, one-sided ID scans).
Non-blocking: advisories are warnings, submitting anyway is always
allowed, and any review failure degrades to an empty list.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [country, numberType, docs]
properties:
country: { type: string, minLength: 2, maxLength: 2 }
numberType: { type: string }
values: { type: object, additionalProperties: { type: string }, description: requirementId to declared textual value. }
address: { type: object, additionalProperties: { type: string }, description: "Declared address (street_address, locality, ...), so a mismatched proof-of-address can be flagged." }
docs:
type: array
minItems: 1
maxItems: 4
items:
type: object
required: [requirementId, documentId]
properties:
requirementId: { type: string }
documentId: { type: string, description: Id from POST /v1/phone-numbers/kyc/upload-document. }
responses:
'200':
description: Advisories (empty when the packet looks fine or the review was unavailable).
content:
application/json:
schema:
type: object
properties:
advisories:
type: array
items:
type: object
properties:
requirementId: { type: string }
concern: { type: string, description: One short plain-language concern about that requirement's document. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/{id}/remediate:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberRemediation
tags: [Phone Numbers]
summary: Get declined requirements
description: |
For a number in `regulatory_declined`, returns ONLY the requirements the
reviewer flagged declined, as a form spec (same shape as the KYC form GET).
The customer fixes just those — Telnyx supports correcting a declined
requirement group and re-submitting it (no new number/group). Falls back
to the full spec if the provider exposes no per-requirement flags.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Phone number record ID. }
responses:
'200':
description: The declined requirements to fix.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
declineReason: { type: [string, "null"] }
fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/phone-numbers/kyc. }
'400': { description: Number is not awaiting remediation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: remediatePhoneNumber
tags: [Phone Numbers]
summary: Resubmit a declined number
description: |
Submit corrected values/documents for the declined requirement(s). We
PATCH them onto the SAME requirement group and re-submit it for approval;
the number goes `regulatory_declined` → `pending_regulatory`. No new
number and no new billing. Body shape matches the KYC submit (values /
documents / address) — send only the corrected fields.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
values: { type: object, additionalProperties: { type: string } }
documents:
type: array
items:
oneOf:
- { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
- { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
address: { type: object, description: Same shape as the KYC submit address. }
responses:
'200':
description: Re-submitted for approval.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: resubmitted }
phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
'400': { description: Number is not awaiting remediation / nothing to remediate }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/remediate/reply:
post:
x-resource-group: "telephony"
operationId: replyToPhoneNumberReviewer
tags: [Phone Numbers]
summary: Reply to the regulatory reviewer
description: |
Post a free-text reply (with optional file attachments) to the reviewer
on a number awaiting remediation — for asks the structured form can't
express (e.g. "is this personal or business?"). Attachments are stored by
us and their links are added to the reviewer's comment thread (the
carrier's number order takes no loose files). A reply to a comment-style
ask moves the number back to "in review"; a reply on a formal decline is
supplementary and you must still resubmit the fix. Requires text or at
least one attachment.
parameters:
- name: id
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
text: { type: string, maxLength: 2000, description: The reply message to the reviewer. }
attachments:
type: array
maxItems: 5
description: 'Files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to the reply.'
items:
type: object
required: [filename, base64]
properties:
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes. }
responses:
'200':
description: Reply posted.
content:
application/json:
schema:
type: object
properties:
posted: { type: boolean }
attachments: { type: integer, description: Number of attachments uploaded. }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'502': { description: Couldn't deliver the reply to the reviewer; retry. }
/v1/phone-numbers/{id}/remediate/respond:
post:
x-resource-group: "telephony"
operationId: respondToPhoneNumberReviewer
tags: [Phone Numbers]
summary: Respond to the regulatory reviewer (message + corrections)
description: |
Send a single response to the reviewer on a number awaiting remediation:
a free-text message and/or corrected requirement documents, in one call.
If corrections are present they are PATCHed onto the requirement group and
re-submitted (the number goes back to "in review"); if a message or file
attachments are present they are posted to the reviewer's comment thread.
When both are present, your message is the thread comment and the resubmit
drives the state change. At least one of message, corrections, or
attachments is required. `documents` correct requirement slots; `attachments`
are loose files (their links are added to your message).
parameters:
- name: id
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message: { type: string, maxLength: 2000, description: Your message to the reviewer. }
documents:
type: array
description: 'Corrected requirement documents, each keyed to its requirement.'
items:
type: object
required: [requirementId]
properties:
requirementId: { type: string }
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes (or supply documentId instead). }
documentId: { type: string, description: Id of a document already uploaded out-of-band. }
address:
type: object
description: A corrected address record, keyed to its requirement.
entityType:
type: [string, 'null']
enum: [individual, business, null]
attachments:
type: array
maxItems: 5
description: 'Loose files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to your message.'
items:
type: object
required: [filename, base64]
properties:
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes. }
responses:
'200':
description: Response sent.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [resubmitted, replied], description: '`resubmitted` when corrections were submitted, `replied` when it was message-only.' }
posted: { type: boolean, description: Whether a message/attachments were posted to the reviewer. }
phoneNumber:
type: object
properties:
id: { type: string }
status: { type: string }
siblingsResubmitted: { type: integer, description: Other numbers on the same registration the correction fanned out to. }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'409': { description: Number's registration is held under our own carrier registration; nothing for you to correct. }
'502': { description: Couldn't deliver your response to the reviewer; retry. }
/v1/whatsapp/phone-numbers/kyc:
get:
x-resource-group: "telephony"
operationId: getWhatsAppNumberKycForm
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Get KYC form spec
description: |
Deprecated alias of `/v1/phone-numbers/kyc`; same contract. New
integrations should use that path.
For a Tier 3/4 country, the fields the end customer must provide (Telnyx
regulatory requirements) before a number can be ordered: text, date,
address, or file (document) per requirement.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string } }
- { name: profileId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: The KYC form spec.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
example: { type: [string, "null"], description: Concrete example value. }
localTo: { type: [string, "null"], description: ISO country the value must be local to }
audience: { type: [string, "null"], enum: [business, individual, null], description: 'When set, the requirement applies ONLY to this end-user type — provide it for that type and OMIT it for the other (e.g. Brazil: "Cartão CNPJ" is business-only, "CPF" and "ID/Passport Copy" are personal-only). Submitting both sets makes the regulator ask whether the number is for personal or business use and stalls the review. Pass `entityType` on POST so the server drops the inapplicable set.' }
reusable:
type: [object, "null"]
description: 'Present when this account already has a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
properties:
available: { type: boolean }
fromPhoneNumber: { type: string }
details:
type: array
description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort — may be empty if the provider lookup fails.
items:
type: object
properties:
label: { type: string }
value: { type: string }
options:
type: array
description: One entry per distinct approved verification, newest first.
items:
type: object
properties:
id: { type: string, description: 'Opaque option id — pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
fromPhoneNumber: { type: string, description: Display only — the number this verification was submitted for. Not a selection key. }
instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
details:
type: array
items:
type: object
properties:
label: { type: string }
value: { type: string }
documentId: { type: string, description: 'Present on document rows — the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
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 — prefer `reuseOptionId`. Omitted = the approved default. No match = 409.' }
areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the number must be in. Hard constraint: an empty area pool fails with 409 code AREA_CODE_UNAVAILABLE instead of ordering from another area. Omit for any area. Options come from GET /v1/phone-numbers/availability (areaOptions); the purchase 202 kycUrl echoes the areaCode picked at purchase time so it can be passed here.' }
endUserFirstName: { type: string, description: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. }
endUserLastName: { type: string, description: End user's legal last name. Same condition as endUserFirstName. }
values:
type: object
additionalProperties: { type: string }
description: requirementId → textual value
documents:
type: array
description: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/whatsapp/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit).
items:
oneOf:
- type: object
required: [requirementId, filename, base64]
properties:
requirementId: { type: string }
filename: { type: string }
base64: { type: string }
- type: object
required: [requirementId, documentId]
properties:
requirementId: { type: string }
documentId: { type: string, description: Id from POST /v1/whatsapp/phone-numbers/kyc/upload-document. }
address:
type: object
properties:
requirementId: { type: string }
country_code: { type: string }
business_name: { type: string }
first_name: { type: string }
last_name: { type: string }
street_address: { type: string }
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] }
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
social account only and leaves the number itself pinned to its original
profile, which splits the number across two profiles. Connecting a
Zernio-provisioned number from any profile but its own is rejected with a
`409` (`WHATSAPP_NUMBER_PINNED_TO_PROFILE`). This endpoint is how you
re-home the number first, so it can then be connected from the new profile.
`id` is the number record id from `GET /v1/phone-numbers`, not an account id.
A profile holds at most one account per platform, so the destination must be
free of every platform this number occupies.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId]
properties:
profileId: { type: string, description: Destination profile id. Must belong to the same team. }
responses:
'200':
description: Number moved, or already on that profile.
content:
application/json:
schema:
type: object
properties:
message: { type: string }
profileId: { type: string, description: The profile the number is now on. }
movedPlatforms:
type: array
items: { type: string }
description: 'Platforms whose accounts travelled with the number (phone, sms, whatsapp). Absent when the number was already on the destination profile.'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'No access to the source or destination profile, or the Inbox add-on is not active.' }
'404': { description: 'Number not found, or the destination profile does not exist.' }
'409': { description: 'The destination profile already holds an account on one of the platforms this number occupies.' }
/v1/whatsapp/phone-numbers/{id}/remediate:
get:
x-resource-group: "telephony"
operationId: getWhatsAppNumberRemediation
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Get declined requirements
description: |
Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
integrations should use that path.
For a number in `regulatory_declined`, returns ONLY the requirements the
reviewer flagged declined, as a form spec (same shape as the KYC form GET).
The customer fixes just those — Telnyx supports correcting a declined
requirement group and re-submitting it (no new number/group). Falls back
to the full spec if the provider exposes no per-requirement flags.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
responses:
'200':
description: The declined requirements to fix.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
declineReason: { type: [string, "null"] }
fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/whatsapp/phone-numbers/kyc. }
'400': { description: Number is not awaiting remediation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: remediateWhatsAppNumber
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Resubmit a declined number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
integrations should use that path.
Submit corrected values/documents for the declined requirement(s). We
PATCH them onto the SAME requirement group and re-submit it for approval;
the number goes `regulatory_declined` → `pending_regulatory`. No new
number and no new billing. Body shape matches the KYC submit (values /
documents / address) — send only the corrected fields.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
values: { type: object, additionalProperties: { type: string } }
documents:
type: array
items:
oneOf:
- { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
- { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
address: { type: object, description: Same shape as the KYC submit address. }
responses:
'200':
description: Re-submitted for approval.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: resubmitted }
phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
'400': { description: Number is not awaiting remediation / nothing to remediate }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/voice:
post:
x-resource-group: "telephony"
operationId: enableVoiceOnNumber
tags: [Voice]
summary: Enable phone calling on a number
description: |
Turns on regular phone (PSTN) calling for one of your numbers and
configures how inbound calls are handled. Inbound calls route to
`forwardTo`: your own AI voice agent (Vapi/Retell), a phone, or a SIP
endpoint. Optional extras: voicemail, business-hours windows, an IVR
menu, a caller blocklist, recording, and transcription. A number can
also be voice-enabled with no forward (outbound-only).
Idempotent, and doubles as the settings update: only fields present in
the body are written. Omitting `forwardTo` preserves the current
destination; sending an empty string clears it.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for inbound calls. Empty string clears the forward (outbound-only); omitted preserves the current one." }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
voicemailEnabled: { type: boolean, description: Voicemail is taken when there's no live destination. Default on. }
voicemailGreeting: { type: string, maxLength: 1000, description: Custom spoken greeting; empty string restores the default. }
businessHoursEnabled: { type: boolean, description: "Outside the windows, inbound skips the forward and goes to voicemail. Off = 24/7." }
businessHoursTimezone: { type: string, maxLength: 64, description: IANA timezone the windows are evaluated in. }
businessHours:
type: array
maxItems: 21
items:
type: object
required: [day, open, close]
properties:
day: { type: integer, minimum: 0, maximum: 6, description: 0 = Sunday. }
open: { type: string, pattern: '^\d{2}:\d{2}$' }
close: { type: string, pattern: '^\d{2}:\d{2}$' }
blockedCallers:
type: array
maxItems: 1000
items: { type: string }
description: E.164 numbers rejected before answer. Replaces the whole list; bare 10-digit values are normalized as US numbers.
forwardCallerId: { type: string, enum: [business, caller], description: "Caller ID on the forwarded leg: your number (`business`) or the original caller's (`caller`)." }
ivrEnabled: { type: boolean, description: IVR menu (supersedes the plain forward within business hours). }
ivrPrompt: { type: string, maxLength: 1000 }
ivrOptions:
type: array
maxItems: 12
items:
type: object
required: [digit, forwardTo]
properties:
digit: { type: string, pattern: '^[0-9*#]$' }
forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for this digit." }
label: { type: string, maxLength: 80 }
responses:
'200':
description: Voice enabled; the full effective voice config is echoed back.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
phoneNumber: { type: string }
pstnForwardTo: { type: [string, "null"] }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
voicemailEnabled: { type: boolean }
voicemailGreeting: { type: [string, "null"] }
businessHoursEnabled: { type: boolean }
businessHoursTimezone: { type: [string, "null"] }
businessHours: { type: array, items: { type: object, properties: { day: { type: integer }, open: { type: string }, close: { type: string } } } }
blockedCallers: { type: array, items: { type: string } }
forwardCallerId: { type: string, enum: [business, caller] }
ivrEnabled: { type: boolean }
ivrPrompt: { type: [string, "null"] }
ivrOptions: { type: array, items: { type: object, properties: { digit: { type: string }, forwardTo: { type: string }, label: { type: string } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'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/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 destination workspaces (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 workspace, or the workspace is on legacy (non-usage-based) billing, which cannot invoice trunk call costs (code feature_not_available).' }
'409': { description: 'The workspace 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 workspace'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 workspace 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 workspace, or the workspace 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 simply reactivated.
- `reusable` set: you have an approved registration this number can
join in one click via
`POST /v1/phone-numbers/{id}/sms/reuse-registration`
(no new brand/campaign, no extra carrier fee).
- `needsRegistration: true` and no `reusable`: start one via
`POST /v1/sms/registrations`.
Idempotent: re-running re-attempts any carrier-side setup that failed.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: "Result. Check `enabled`: a 200 with `enabled: false` means the number can't do SMS (`smsCapable: false`) or isn't ready yet (`notReady: true`)."
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
id: { type: string, description: The SMS social account ID (present when enabled). }
phoneNumber: { type: string }
isActive: { type: boolean, description: False for US numbers until their registration is approved. }
country: { type: string }
smsCapable: { type: [boolean, "null"], description: Null when capability can't be read yet (still provisioning). }
mmsCapable: { type: boolean }
domesticOnly: { type: boolean }
notReady: { type: boolean, description: Number is still provisioning at the carrier; retry shortly. }
needsRegistration: { type: boolean, description: US only; a carrier registration is required before delivery. }
alreadyRegistered: { type: boolean, description: A prior non-rejected registration already covers this number; no re-submit needed. }
registrationStatus: { type: [string, "null"], enum: [pending, approved, rejected, null] }
reusable:
type: [object, "null"]
description: Present when an existing approved registration can cover this number via /sms/reuse-registration.
properties:
registrationId: { type: string }
status: { type: string }
message: { type: string, description: Human-readable explanation when `enabled` is false. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'422': { description: "This number is hosted by your own carrier (brought via WhatsApp embedded signup), so SMS can't be enabled on it." }
delete:
x-resource-group: "telephony"
operationId: disableSmsOnNumber
tags: [SMS]
summary: Disable SMS on a number
description: |
Turns off SMS for the number (deactivates its SMS account). The carrier
registration is untouched, so re-enabling later just reactivates it,
with no re-registration.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: SMS disabled.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean, description: Always false after a successful disable. }
phoneNumber: { type: string }
disabled: { type: boolean, description: False when SMS was already off. Legacy field; prefer `enabled`. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/sms/reuse-registration:
post:
x-resource-group: "telephony"
operationId: reuseSmsRegistrationForNumber
tags: [SMS]
summary: Add number to SMS registration
description: |
Attaches this number to your existing approved 10DLC campaign instead
of running a fresh registration: the number inherits the campaign's
approval (no new brand or campaign, no extra carrier fee). Enable SMS
on the number first (`POST /v1/phone-numbers/{id}/sms`; its response
tells you whether a reusable registration exists).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Number added to the existing registration.
content:
application/json:
schema:
type: object
properties:
registrationId: { type: string }
status: { type: string, enum: [pending, approved, rejected, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'409': { description: No existing SMS registration to reuse for this number }
/v1/phone-numbers/{id}/whatsapp/calling:
get:
x-resource-group: "telephony"
operationId: getWhatsAppCalling
tags: [WhatsApp Calling]
summary: Get calling config for a number
description: |
The WhatsApp Business Calling configuration of this number, keyed the
same way as the POST/PATCH/DELETE below (full read-write on one
sub-resource). Encrypted secrets are never returned; only a boolean
saying whether a SIP password is stored. The account-scoped read
(`GET /v1/whatsapp/calling?accountId=`) remains for callers that only
know the social account id, and additionally carries account-level
extras (billing eligibility, current-period spend).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: Calling config
content:
application/json:
schema:
type: object
properties:
phoneNumber: { type: string }
callingEnabled: { type: boolean }
callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Null while calling is disabled." }
forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
recordingEnabled: { type: boolean }
sipAuthUsername: { type: [string, "null"] }
sipAuthPasswordConfigured: { type: boolean, description: True when a SIP digest password is stored. The plaintext is never returned. }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
outboundDisabled: { type: boolean, description: "True when the number's country blocks business-initiated (outbound) WhatsApp calling; inbound still works." }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (used when the number was brought by the customer and its caller ID is not verified for PSTN origination).'
callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification, making tel: forwards display the business number itself.' }
maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
forwardCallerId: { type: string, enum: [business, caller] }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: enableWhatsAppCalling
tags: [WhatsApp Calling]
summary: Enable calling on a number
description: |
Enable WhatsApp Business Calling on a connected number. Configures
Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
stores the Meta-issued SIP password (encrypted), and snapshots the
customer's forward-to destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, forwardTo]
properties:
accountId: { type: string }
forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
sipAuthUsername: { type: string }
sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
recordingEnabled: { type: boolean, default: false }
callIconCountries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
responses:
'200':
description: Calling enabled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callingEnabled: { type: boolean }
sipHostname: { type: string }
forwardTo: { type: string }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
'400': { description: 'Invalid request (including forwardTo set to the number itself)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Phone number not found }
'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 social account
2. Decrement the Stripe subscription quantity (or cancel if last number)
3. Release the number from Telnyx
4. Mark the number as released
security:
- bearerAuth: []
parameters:
- name: phoneNumberId
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number released successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, description: "\"released\"" }
releasedAt: { type: string, format: date-time }
'400': { description: Phone number is already released or being released }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'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 social account ID }
- { name: limit, in: query, schema: { type: integer, default: 25, maximum: 1024 }, description: Max groups to return }
- { name: after, in: query, schema: { type: string }, description: Pagination cursor }
responses:
'200':
description: List of active groups
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: object
properties:
id: { type: string, description: Group ID }
subject: { type: string, description: Group name }
createdAt: { type: string, description: Group creation timestamp }
paging:
type: object
properties:
cursors:
type: object
properties:
after: { type: string }
before: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createWhatsAppGroupChat
tags: [WhatsApp]
summary: Create group
description: |
Create a new WhatsApp group chat. Returns the group ID and optionally an invite link.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, subject]
properties:
accountId: { type: string, description: WhatsApp social account ID }
subject: { type: string, maxLength: 128, description: Group name (max 128 characters) }
description: { type: string, maxLength: 2048, description: Group description (max 2048 characters) }
joinApprovalMode:
type: string
enum: [approval_required, auto_approve]
description: Whether users need approval to join via invite link
responses:
'201':
description: Group created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
groupId: { type: string }
inviteLink: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}:
get:
x-resource-group: "messages"
operationId: getWhatsAppGroupChat
tags: [WhatsApp]
summary: Get group info
description: |
Retrieve metadata about a WhatsApp group including subject, description,
participants, and settings.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Group info
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
id: { type: string }
subject: { type: string }
description: { type: string }
joinApprovalMode: { type: string }
participants:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
admin: { type: string }
participantCount: { type: integer }
createdAt: { type: integer, description: UNIX timestamp }
isSuspended: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
post:
x-resource-group: "messages"
operationId: updateWhatsAppGroupChat
tags: [WhatsApp]
summary: Update group settings
description: |
Update the subject, description, or join approval mode of a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
subject: { type: string, maxLength: 128 }
description: { type: string, maxLength: 2048 }
joinApprovalMode: { type: string, enum: [approval_required, auto_approve] }
responses:
'200':
description: Group updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteWhatsAppGroupChat
tags: [WhatsApp]
summary: Delete group
description: |
Delete a WhatsApp group and remove all participants.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Group deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/whatsapp/wa-groups/{groupId}/participants:
post:
x-resource-group: "messages"
operationId: addWhatsAppGroupParticipants
tags: [WhatsApp]
summary: Add participants
description: |
Add participants to a WhatsApp group. Maximum 8 participants per request.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
maxItems: 8
items: { type: string }
description: Phone numbers in E.164 format (max 8)
responses:
'200':
description: Participants added
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "messages"
operationId: removeWhatsAppGroupParticipants
tags: [WhatsApp]
summary: Remove participants
description: |
Remove participants from a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to remove
responses:
'200':
description: Participants removed
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/invite-link:
post:
x-resource-group: "messages"
operationId: createWhatsAppGroupInviteLink
tags: [WhatsApp]
summary: Create invite link
description: |
Create a new invite link for a WhatsApp group. The previous link is revoked.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Invite link created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
inviteLink: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/join-requests:
get:
x-resource-group: "messages"
operationId: listWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: List join requests
description: |
List pending join requests for a WhatsApp group (only for groups with approval_required mode).
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Join requests
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
joinRequests:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
timestamp: { type: integer, description: UNIX timestamp of request }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: approveWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Approve join requests
description: |
Approve pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to approve
responses:
'200':
description: Requests approved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "messages"
operationId: rejectWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Reject join requests
description: |
Reject pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to reject
responses:
'200':
description: Requests rejected
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
# ─── WhatsApp Flows ───────────────────────────────────────────────
/v1/whatsapp/flows:
get:
x-resource-group: "accounts"
operationId: listWhatsAppFlows
tags: [WhatsApp Flows]
summary: List flows
description: |
List all WhatsApp Flows for the Business Account (WABA) associated with the given account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Flows retrieved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
flows:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string, enum: [DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED] }
categories:
type: array
items: { type: string }
validation_errors:
type: array
items: { type: object }
version:
type: integer
description: 1-based version within the flow's clone lineage (Zernio-tracked; Meta has no native versioning). Standalone flows are version 1.
lineageId:
type: string
description: Stable group key for the flow's version lineage (the root flow's ID).
'400': { description: WABA ID not found on account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: createWhatsAppFlow
tags: [WhatsApp Flows]
summary: Create flow
description: |
Create a new WhatsApp Flow in DRAFT status. Optionally clone an existing flow.
After creating, upload a Flow JSON definition, then publish to make it sendable.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, categories]
properties:
accountId: { type: string, description: WhatsApp social account ID }
name: { type: string, maxLength: 128, description: Flow display name }
categories:
type: array
minItems: 1
items:
type: string
enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
description: Flow categories
cloneFlowId: { type: string, description: "Optional: ID of an existing flow to clone the Flow JSON from" }
asVersion: { type: boolean, description: "When cloning, true keeps the clone in cloneFlowId's version lineage (auto-numbered next version); false/absent creates an independent flow. Ignored without cloneFlowId." }
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 social account ID }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated fields to return (default: id,name,status,categories,validation_errors,json_version,preview,data_api_version,endpoint_uri)" }
responses:
'200':
description: Flow details
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
flow:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string }
categories:
type: array
items: { type: string }
validation_errors:
type: array
items: { type: object }
json_version: { type: string }
preview:
type: object
properties:
preview_url: { type: string }
expires_at: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
patch:
x-resource-group: "accounts"
operationId: updateWhatsAppFlow
tags: [WhatsApp Flows]
summary: Update flow
description: |
Update metadata (name, categories, 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 social account ID }
name: { type: string, maxLength: 128, description: New flow name }
categories:
type: array
minItems: 1
items:
type: string
enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
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 social account ID }
responses:
'200':
description: Flow deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in DRAFT status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account or flow not found }
/v1/whatsapp/flows/{flowId}/json:
get:
x-resource-group: "accounts"
operationId: getWhatsAppFlowJson
tags: [WhatsApp Flows]
summary: Get flow JSON asset
description: |
Get the flow JSON asset metadata, including a temporary download URL for the Flow JSON file.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Flow JSON asset
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
assets:
type: array
items:
type: object
properties:
name: { type: string, example: flow.json }
asset_type: { type: string, example: FLOW_JSON }
download_url: { type: string, description: Temporary URL to download the flow JSON }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
put:
x-resource-group: "accounts"
operationId: uploadWhatsAppFlowJson
tags: [WhatsApp Flows]
summary: Upload flow JSON
description: |
Upload or update the Flow JSON for a DRAFT flow. The Flow JSON defines all screens,
components (text inputs, dropdowns, date pickers, etc.), and navigation.
Meta validates the JSON on upload and returns any validation errors.
See: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, flow_json]
properties:
accountId: { type: string, description: WhatsApp social account ID }
flow_json:
description: "The Flow JSON content. Pass as a JSON object or a JSON string."
oneOf:
- type: object
- type: string
examples:
simple_form:
summary: Simple lead capture form
value:
accountId: "507f1f77bcf86cd799439011"
flow_json:
version: "6.0"
screens:
- id: "LEAD_FORM"
title: "Get a Quote"
terminal: true
success: true
layout:
type: "SingleColumnLayout"
children:
- type: "TextInput"
name: "full_name"
label: "Full Name"
required: true
input-type: "text"
- type: "TextInput"
name: "email"
label: "Email"
required: true
input-type: "email"
- type: "Footer"
label: "Submit"
on-click-action:
name: "complete"
payload:
full_name: "${form.full_name}"
email: "${form.email}"
responses:
'200':
description: Flow JSON uploaded
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
validation_errors:
type: array
description: "Empty array if valid; otherwise, contains validation error details from Meta"
items:
type: object
properties:
error: { type: string }
error_type: { type: string }
message: { type: string }
line_start: { type: integer }
line_end: { type: integer }
column_start: { type: integer }
column_end: { type: integer }
'400': { description: Invalid JSON or flow is not in DRAFT status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/preview:
get:
x-resource-group: "accounts"
operationId: getWhatsAppFlowPreview
tags: [WhatsApp Flows]
summary: Get flow preview URL
description: |
Get Meta's public web-preview URL for a flow (drafts included), embeddable as an
interactive iframe. The link is reused across calls (valid ~30 days); pass
invalidate=true to mint a fresh one (the previous link stops working).
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: invalidate, in: query, required: false, schema: { type: boolean }, description: Mint a fresh preview link (default false) }
responses:
'200':
description: Preview URL
content:
application/json:
schema:
type: object
properties:
preview_url: { type: [string, "null"] }
expires_at: { type: [string, "null"] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
/v1/whatsapp/flows/{flowId}/versions:
get:
x-resource-group: "accounts"
operationId: listWhatsAppFlowVersions
tags: [WhatsApp Flows]
summary: List flow versions
description: |
List the flow's version history (the clone lineage Zernio tracks, since Meta has no
native versioning), newest version first. Each entry is enriched with the version's
live name and status from Meta. A flow with no lineage returns just itself as version 1.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Version history
content:
application/json:
schema:
type: object
properties:
versions:
type: array
items:
type: object
properties:
flowId: { type: string }
version: { type: integer }
parentFlowId: { type: [string, "null"] }
name: { type: [string, "null"] }
status: { type: [string, "null"] }
missing: { type: boolean, description: True when Meta no longer has this flow }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
/v1/whatsapp/flows/{flowId}/publish:
post:
x-resource-group: "accounts"
operationId: publishWhatsAppFlow
tags: [WhatsApp Flows]
summary: Publish flow
description: |
Publish a DRAFT flow. This is irreversible. Once published, the flow and its JSON
become immutable and the flow can be sent to users. To update a published flow,
create a new flow (optionally cloning this one via cloneFlowId).
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp social account ID }
responses:
'200':
description: Flow published
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in DRAFT status or has validation errors }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/deprecate:
post:
x-resource-group: "accounts"
operationId: deprecateWhatsAppFlow
tags: [WhatsApp Flows]
summary: Deprecate flow
description: |
Deprecate a PUBLISHED flow. This is irreversible. Deprecated flows cannot be sent
or opened, but existing active sessions may continue until they complete.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp social account ID }
responses:
'200':
description: Flow deprecated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in PUBLISHED status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/send:
post:
x-resource-group: "messages"
operationId: sendWhatsAppFlowMessage
tags: [WhatsApp Flows]
summary: Send flow message
description: |
Send a published flow as an interactive message with a CTA button.
When the recipient taps the button, the flow opens natively in WhatsApp.
Flow responses are received via webhooks.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, to, flow_id, flow_cta, body]
properties:
accountId: { type: string, description: WhatsApp social account ID }
to: { type: string, description: "Recipient phone number (E.164 format, e.g. +1234567890)" }
flow_id: { type: string, description: Published flow ID }
flow_cta: { type: string, maxLength: 20, description: "CTA button text (e.g. 'Book Now', 'Sign Up')" }
flow_action:
type: string
enum: [navigate, data_exchange]
default: navigate
description: "Action type: navigate opens a screen directly, data_exchange hits your endpoint first"
flow_token: { type: string, maxLength: 200, description: "Unique token to correlate responses. If omitted, auto-generated as '<flowId>:<uuid>' so the response can be attributed to this flow in the Flow Responses view." }
flow_action_payload:
type: object
properties:
screen: { type: string, description: First screen ID to navigate to }
data:
type: object
description: Optional data to pass to the screen
body: { type: string, description: Message body text }
header:
type: object
properties:
type: { type: string, enum: [text] }
text: { type: string }
footer: { type: string, description: Optional footer text }
draft: { type: boolean, description: "Set true to test an unpublished (DRAFT) flow" }
examples:
basic:
summary: Send a lead capture flow
value:
accountId: "507f1f77bcf86cd799439011"
to: "+1234567890"
flow_id: "1234567890"
flow_cta: "Get a Quote"
flow_action: "navigate"
flow_action_payload:
screen: "LEAD_FORM"
body: "Hi! Fill out this quick form to get a personalized quote."
responses:
'200':
description: Flow message sent
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
messageId: { type: string, description: WhatsApp message ID (WAMID) }
'400': { description: Validation error or missing phone number ID }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flow-responses:
get:
x-resource-group: "messages"
operationId: listWhatsAppFlowResponses
tags: [WhatsApp Flows]
summary: List flow responses
description: |
List the responses customers submitted when completing a flow (parsed from the
nfm_reply messages received via webhook), newest first. Scope to a single flow
with `flowId` — this matches responses whose flow_token carries the `<flowId>:`
prefix that Zernio stamps on auto-generated tokens at send time. Responses sent
with a custom integrator-supplied flow_token are not attributed to a flow.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: flowId, in: query, required: false, schema: { type: string }, description: Scope to responses for this flow }
- { name: limit, in: query, required: false, schema: { type: integer, maximum: 200, default: 50 }, description: Max responses to return }
responses:
'200':
description: Flow responses
content:
application/json:
schema:
type: object
properties:
responses:
type: array
items:
type: object
properties:
id: { type: string, description: Message ID }
receivedAt: { type: string, format: date-time }
from: { type: [string, "null"], description: Sender wa_id / phone }
senderName: { type: [string, "null"] }
conversationId: { type: [string, "null"] }
flowToken: { type: [string, "null"] }
data: { type: object, description: Submitted field values (flow_token removed) }
raw: { type: [string, "null"], description: Raw response_json string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
# ─── Contacts ─────────────────────────────────────────────────────
/v1/contacts:
get:
x-resource-group: "contacts"
operationId: listContacts
summary: List contacts
description: List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and 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] } }
- { name: isSubscribed, in: query, schema: { type: string, enum: ['true', 'false'] } }
- { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Contacts list with pagination and filter metadata
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contacts:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
lastMessageSentAt: { type: string, format: date-time }
lastMessageReceivedAt: { type: string, format: date-time }
messagesSentCount: { type: integer }
messagesReceivedCount: { type: integer }
customFields: { type: object }
notes: { type: string }
createdAt: { type: string, format: date-time }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
filters:
type: object
properties:
tags: { type: array, items: { type: string } }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "contacts"
operationId: createContact
summary: Create contact
description: Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier.
tags: [Contacts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name]
properties:
profileId: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean, default: true }
notes: { type: string }
accountId: { type: string, description: Optional. Creates a channel if provided with platform + platformIdentifier }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
responses:
'200':
description: Contact created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
customFields: { type: object }
notes: { type: string }
createdAt: { type: string, format: date-time }
channel:
type: object
description: Created when accountId, platform, and platformIdentifier are provided
properties:
id: { type: string }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
warning: { type: string }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: 'Duplicate channel. The platformIdentifier is already bound to a channel on this accountId.' }
/v1/contacts/{contactId}:
get:
x-resource-group: "contacts"
operationId: getContact
summary: Get contact
description: Returns a contact with all associated messaging channels.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Contact with channels
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
messagesSentCount: { type: integer, description: 'Messages sent to the contact, derived live from message history across all linked conversations.' }
messagesReceivedCount: { type: integer, description: 'Messages received from the contact, derived live from message history across all linked conversations.' }
lastMessageSentAt: { type: [string, "null"], format: date-time, description: 'Timestamp of the most recent outgoing message, or null if none.' }
lastMessageReceivedAt: { type: [string, "null"], format: date-time, description: 'Timestamp of the most recent incoming message, or null if none.' }
customFields: { type: object }
notes: { type: string }
conversationIds: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
channels:
type: array
items:
type: object
properties:
id: { type: string }
accountId: { type: string }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
isSubscribed: { type: boolean }
conversationId: { type: string }
lastActiveAt: { type: [string, "null"], format: date-time, description: 'Most recent message (either direction) in this channel''s conversation, or null if none.' }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "contacts"
operationId: updateContact
summary: Update contact
description: Update one or more fields on a contact. Only provided fields are changed.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
notes: { type: string }
responses:
'200':
description: Contact updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
notes: { type: string }
updatedAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "contacts"
operationId: deleteContact
summary: Delete contact
description: Permanently deletes a contact and all associated channels.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Contact deleted }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/contacts/{contactId}/channels:
get:
x-resource-group: "contacts"
operationId: getContactChannels
summary: List channels for a contact
description: Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp).
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: List of contact channels
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
channels:
type: array
items:
type: object
properties:
id: { type: string }
accountId: { type: string }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
isSubscribed: { type: boolean }
conversationId: { type: string }
metadata: { type: object }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/contacts/bulk:
post:
x-resource-group: "contacts"
operationId: bulkCreateContacts
summary: Bulk create contacts
description: 'Import up to 1000 contacts at a time. Skips duplicates, merging any new tags onto the existing contact. accountId is required whenever contacts carry a platformIdentifier (or a row-level accountId); platform is always derived from the resolved account, never used to decide whether channels are created, and a mismatched platform 404s as account not found. When accountId is set, each contact must carry a platformIdentifier; a row missing it is rejected individually (reported in errors[], HTTP 200), not a 400 for the whole import. On phone platforms (whatsapp, sms) the platformIdentifier is normalized to digits and a value that is not phone-shaped is rejected per contact and reported in errors[], not imported.'
tags: [Contacts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, contacts]
properties:
profileId: { type: string }
accountId: { type: string, description: 'Required when contacts carry channel data (platformIdentifier or a row-level accountId). Omit for a plain CRM import with no channels.' }
platform: { type: string, description: 'Ignored when accountId is set: the platform is derived from the resolved account. Only relevant to disambiguate accountId lookup; a mismatch 404s.' }
contacts:
type: array
maxItems: 1000
items:
type: object
required: [name]
properties:
name: { type: string }
platformIdentifier: { type: string, description: 'Required when the top-level accountId is set (channel mode). A row missing it in that mode is rejected individually and reported in errors[], not a 400 for the whole import.' }
displayIdentifier: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
responses:
'200':
description: Bulk import results
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
created: { type: integer }
skipped: { type: integer }
errors: { type: array, items: { type: string }, description: 'Per-contact failures, e.g. an identifier that is not a valid phone number' }
total: { type: integer }
'400': { description: 'Contact missing required field name, or a row carries platformIdentifier/accountId with no top-level accountId to attach it to. A row missing platformIdentifier while accountId IS set is not a 400: it is reported in errors[] under a 200.' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/contacts/{contactId}/fields/{slug}:
put:
x-resource-group: "contacts"
operationId: setContactFieldValue
summary: Set custom field value
description: Set or overwrite a custom field value on a contact. The value type must match the field definition.
tags: [Custom Fields]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
- { name: slug, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value: { description: Field value (type depends on field definition) }
responses:
'200': { description: Field value set }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "contacts"
operationId: clearContactFieldValue
summary: Clear custom field value
description: Remove a custom field value from a contact. The field definition is not affected.
tags: [Custom Fields]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
- { name: slug, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Field value cleared }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Custom Fields ────────────────────────────────────────────────
/v1/custom-fields:
get:
x-resource-group: "accounts"
operationId: listCustomFields
summary: List custom field definitions
description: Returns all custom field definitions. Optionally filter by profile.
tags: [Custom Fields]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
responses:
'200':
description: List of custom field definitions
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
fields:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'400': { description: Invalid profileId format }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "accounts"
operationId: createCustomField
summary: Create custom field
description: Create a new custom field definition. Supported types are text, number, date, boolean, and select.
tags: [Custom Fields]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, type]
properties:
profileId: { type: string }
name: { type: string }
slug: { type: string, description: Auto-generated from name if not provided }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string }, description: Required for select type }
responses:
'200':
description: Custom field created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request body }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Duplicate slug }
/v1/custom-fields/{fieldId}:
patch:
x-resource-group: "accounts"
operationId: updateCustomField
summary: Update custom field
description: Update a custom field definition. The field type cannot be changed after creation.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
options: { type: array, items: { type: string } }
responses:
'200':
description: Custom field updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string }
options: { type: array, items: { type: string } }
'400': { description: Invalid fieldId or request body }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "accounts"
operationId: deleteCustomField
summary: Delete custom field
description: Delete a custom field definition and remove its values from all contacts.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Custom field deleted }
'400': { description: Invalid fieldId format }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Broadcasts ───────────────────────────────────────────────────
/v1/broadcasts:
get:
x-resource-group: "messages"
operationId: listBroadcasts
summary: List broadcasts
description: Returns broadcasts with delivery stats. Filter by status, platform, or profile.
tags: [Broadcasts]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } }
- { name: platform, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Broadcasts list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcasts:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
messagePreview: { type: string, description: Template name or message text snippet }
scheduledAt: { type: string, format: date-time }
startedAt: { type: string, format: date-time }
completedAt: { type: string, format: date-time }
recipientCount: { type: integer }
sentCount: { type: integer }
deliveredCount: { type: integer }
readCount: { type: integer }
failedCount: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createBroadcast
summary: Create broadcast draft
description: Create a broadcast in draft status. Add recipients and then send or schedule it.
tags: [Broadcasts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
name: { type: string }
description: { type: string }
message:
type: object
properties:
text: { type: string }
attachments: { type: array, items: { type: object, properties: { type: { type: string }, url: { type: string }, filename: { type: string } } } }
template:
type: object
description: WhatsApp template (required when platform is whatsapp)
properties:
name: { type: string }
language: { type: string }
components: { type: array, items: { type: object, additionalProperties: true } }
variableMapping:
type: object
description: Maps template variable positions ("1", "2") to contact fields or static values. Resolved per recipient at send time.
additionalProperties:
type: object
properties:
field: { type: string, enum: [name, phone, email, company, custom] }
customValue: { type: string, description: Static value used when field is "custom" }
segmentFilters:
type: object
properties:
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
responses:
'200':
description: Broadcast created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
status: { type: string }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/broadcasts/{broadcastId}:
get:
x-resource-group: "messages"
operationId: getBroadcast
summary: Get broadcast details
description: Returns a broadcast with its full configuration and delivery stats.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast details with stats
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
message: { type: object, properties: { text: { type: string } } }
template: { type: object, properties: { name: { type: string }, language: { type: string } } }
segmentFilters: { type: object, properties: { tags: { type: array, items: { type: string } } } }
status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
scheduledAt: { type: string, format: date-time }
startedAt: { type: string, format: date-time }
completedAt: { type: string, format: date-time }
recipientCount: { type: integer }
sentCount: { type: integer }
deliveredCount: { type: integer }
readCount: { type: integer }
failedCount: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "messages"
operationId: updateBroadcast
summary: Update broadcast
description: Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
description: { type: string }
message:
type: object
description: Generic message payload (used for non-WhatsApp platforms).
properties:
text: { type: string }
template:
type: object
description: WhatsApp template payload (used when platform is `whatsapp`).
properties:
name: { type: string }
language: { type: string }
variableMapping:
type: object
description: Maps template variable positions to contact fields. Keys are position strings ("1", "2"); values are { field, customValue }.
additionalProperties:
type: object
properties:
field: { type: string, enum: [name, phone, email, company, custom] }
customValue: { type: string }
segmentFilters:
type: object
description: Recipient segment filters (tags, channels, subscription state).
responses:
'200':
description: Broadcast updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteBroadcast
summary: Delete broadcast
description: Permanently delete a broadcast. Only drafts can be deleted.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Broadcast deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/send:
post:
x-resource-group: "messages"
operationId: sendBroadcast
summary: Send broadcast now
description: Immediately start sending a draft broadcast to its recipients.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast sending started
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
status: { type: string, enum: [sending, completed, failed], description: Current broadcast status after processing first batch }
sent: { type: integer, description: Recipients sent in this batch }
failed: { type: integer, description: Recipients failed in this batch }
recipientCount: { type: integer, description: Total recipient count }
'400': { description: Invalid status or no recipients }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/schedule:
post:
x-resource-group: "messages"
operationId: scheduleBroadcast
summary: Schedule broadcast for later
description: Schedule a draft broadcast to be sent at a future date and time.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scheduledAt]
properties:
scheduledAt: { type: string, format: date-time }
responses:
'200':
description: Broadcast scheduled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
status: { type: string }
scheduledAt: { type: string, format: date-time }
'400': { description: Invalid date or status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/cancel:
post:
x-resource-group: "messages"
operationId: cancelBroadcast
summary: Cancel broadcast
description: Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast cancelled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Cannot cancel in current status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/recipients:
get:
x-resource-group: "messages"
operationId: listBroadcastRecipients
summary: List broadcast recipients
description: Returns recipients for a broadcast with individual delivery status. Filter by status.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [pending, sent, delivered, read, failed] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Recipients list with delivery status
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
recipients:
type: array
items:
type: object
properties:
id: { type: string }
contactId: { type: string }
channelId: { type: string }
platformIdentifier: { type: string }
contactName: { type: [string, "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, or a WhatsApp-only node on another platform)" }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/workflows/{workflowId}:
get:
x-resource-group: "accounts"
operationId: getWorkflow
summary: Get workflow with graph
description: Returns a workflow including its full node/edge graph and run stats.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Workflow details
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
profileId: { type: string }
status: { type: string, enum: [draft, active, paused] }
entryNodeId: { type: string }
nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
totalStarted: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "accounts"
operationId: updateWorkflow
summary: Update workflow
description: >
Update name, description, the graph, or reassign to a different account. The graph can only
be modified while the workflow is draft or paused. Account swaps re-validate the graph
against the new platform (so e.g. moving from WhatsApp to Facebook surfaces a `start_call`
node as an error instead of silently saving an unrunnable graph).
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
description: { type: string }
nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
entryNodeId: { type: [string, "null"] }
accountId:
type: string
description: >
Reassign the workflow to a different `SocialAccount`. `platform` and `profileId`
are derived server-side from the new account (the client never sends them
directly). The account must belong to the caller's workspace and be on a
workflow-supported platform (whatsapp, instagram, facebook, telegram, twitter,
bluesky, reddit). Changing this triggers a graph revalidation against the new
platform.
responses:
'200':
description: Workflow updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
entryNodeId: { type: string }
nodeCount: { type: integer }
updatedAt: { type: string, format: date-time }
'400': { description: "Invalid graph, or a graph edit attempted while the workflow is active" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "accounts"
operationId: deleteWorkflow
summary: Delete workflow
description: Permanently delete a workflow and all of its executions.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Workflow deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/activate:
post:
x-resource-group: "accounts"
operationId: activateWorkflow
summary: Activate workflow
description: Validate the graph is runnable and set the workflow live. Once active, matching inbound messages start executions. Idempotent.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Workflow activated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
status: { type: string }
entryNodeId: { type: string }
'400': { description: Incomplete or invalid graph }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/pause:
post:
x-resource-group: "accounts"
operationId: pauseWorkflow
summary: Pause workflow
description: Stop matching new inbound messages. In-flight executions continue to completion. Idempotent.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Workflow paused
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/executions:
get:
x-resource-group: "messages"
operationId: listWorkflowExecutions
summary: List workflow runs
description: Returns recent executions (runs) with their status, current node, and accumulated variables.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [running, waiting, completed, exited, failed] } }
- { name: limit, in: query, schema: { type: integer, default: 25 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Executions list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
executions:
type: array
items:
type: object
properties:
id: { type: string }
status: { type: string, enum: [running, waiting, completed, exited, failed] }
currentNodeId: { type: string }
waitingFor:
type: [object, "null"]
properties:
kind: { type: string, enum: [timer, reply] }
nodeId: { type: string }
variables: { type: object, additionalProperties: true }
platformIdentifier: { type: string }
conversationId: { type: string }
stepCount: { type: integer }
lastError: { type: [string, "null"] }
resumeAt: { type: [string, "null"], format: date-time }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
completedAt: { type: [string, "null"], format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
post:
x-resource-group: "messages"
operationId: triggerWorkflow
summary: Manually start a workflow run
description: >
Kick off a run without waiting for an inbound message (useful for testing). Target an existing
conversation by `conversationId`, or — WhatsApp only — a phone number via `to` (a conversation is
found or created). `text` seeds the run's `lastMessage` variable. The graph must be runnable.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
description: Provide either `to` (WhatsApp phone) or `conversationId`.
properties:
to: { type: string, description: Recipient phone (WhatsApp only) }
conversationId: { type: string, description: An existing conversation to run in (required for non-WhatsApp workflows) }
text: { type: string, description: "Simulated inbound text, seeded as the run's lastMessage variable" }
responses:
'200':
description: Run started
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
execution:
type: [object, "null"]
properties:
id: { type: string }
status: { type: string }
currentNodeId: { type: string }
waitingFor: { type: [object, "null"] }
variables: { type: object, additionalProperties: true }
conversationId: { type: string }
'400': { description: "Missing target, invalid graph, or `to` used on a non-WhatsApp workflow" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/executions/{executionId}/events:
get:
x-resource-group: "messages"
operationId: listWorkflowExecutionEvents
summary: Get an execution's timeline
description: >
Returns the per-step run-log for a single workflow execution: trigger fired, each node
visited, edge handles taken, errors, and durations. Backed by Tinybird (90-day retention).
Used by the Runs UI drawer to render the timeline.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: executionId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Timeline events for the execution
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
execution:
type: object
properties:
id: { type: string }
status: { type: string, enum: [running, waiting, completed, exited, failed] }
startedAt: { type: [string, "null"], format: date-time }
completedAt: { type: [string, "null"], format: date-time }
events:
type: array
description: Events in chronological order (oldest first).
items: { $ref: '#/components/schemas/WorkflowExecutionEvent' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/duplicate:
post:
x-resource-group: "accounts"
operationId: duplicateWorkflow
summary: Duplicate a workflow
description: >
Create an independent copy of a workflow's graph, name, description, and account binding.
The copy is created in `draft` status with fresh execution counters and a new id —
execution history is NOT copied. Useful for branching off a known-good workflow before
making experimental edits.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'201':
description: Workflow duplicated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string, enum: [draft] }
platform: { type: string }
accountId: { type: string }
profileId: { type: string }
entryNodeId: { type: [string, "null"] }
nodeCount: { type: integer }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions:
get:
x-resource-group: "accounts"
operationId: listWorkflowVersions
summary: List a workflow's version history
description: >
Returns the snapshot history. A new version is recorded automatically before every PATCH
to `nodes` / `edges` / `entryNodeId`, and explicitly when a previous version is restored.
Lightweight list — call `getWorkflowVersion` for the full snapshot graph.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Versions list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
versions:
type: array
description: Versions in reverse chronological order (newest first).
items:
type: object
properties:
version: { type: integer, description: Monotonically increasing version number }
name: { type: string }
description: { type: [string, "null"] }
createdBy: { type: [string, "null"], description: User id that authored this version }
createdByEmail: { type: [string, "null"], description: Denormalized email so the history UI can render without a join }
restoredFromVersion: { type: [integer, "null"], description: "When non-null, this snapshot was created by restoring that version" }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions/{version}:
get:
x-resource-group: "accounts"
operationId: getWorkflowVersion
summary: Get a specific workflow version
description: Returns the full snapshot for a single historical version, including the graph.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: version, in: path, required: true, schema: { type: integer } }
responses:
'200':
description: Version snapshot
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
version:
type: object
properties:
version: { type: integer }
name: { type: string }
description: { type: [string, "null"] }
entryNodeId: { type: [string, "null"] }
nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
platform: { type: string }
accountId: { type: string }
profileId: { type: string }
createdBy: { type: [string, "null"] }
createdByEmail: { type: [string, "null"] }
restoredFromVersion: { type: [integer, "null"] }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions/{version}/restore:
post:
x-resource-group: "accounts"
operationId: restoreWorkflowVersion
summary: Restore a workflow version
description: >
Replace the current graph with the named version's snapshot. Before the swap, the current
graph is itself snapshotted as a new version, so a restore is reversible. The workflow must
be in `draft` or `paused` status (same gate as a normal graph edit). The returned workflow
carries `restoredFromVersion` so the UI can surface which version was rolled back to.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: version, in: path, required: true, schema: { type: integer } }
responses:
'200':
description: Workflow restored to the named version
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
entryNodeId: { type: [string, "null"] }
nodeCount: { type: integer }
updatedAt: { type: string, format: date-time }
restoredFromVersion: { type: integer }
'400': { description: "Workflow is not draft/paused, or the named version's graph is invalid for the current platform" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences:
get:
x-resource-group: "messages"
operationId: listSequences
summary: List sequences
description: Returns sequences with enrollment stats. Filter by status, platform, or profile.
tags: [Sequences]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, active, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Sequences list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequences:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
messagePreview: { type: string, description: First step template name or message text snippet }
status: { type: string, enum: [draft, active, paused] }
stepsCount: { type: integer }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
totalEnrolled: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createSequence
summary: Create sequence
description: Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template.
tags: [Sequences]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
name: { type: string }
description: { type: string }
steps:
type: array
items:
type: object
required: [order, delayMinutes]
properties:
order: { type: integer }
delayMinutes: { type: integer }
message: { type: object, properties: { text: { type: string } } }
template:
type: object
properties:
name: { type: string }
language: { type: string }
variableMapping:
type: object
description: Maps template variable positions to contact fields. Keys are position strings ("1", "2"), values are objects with field and optional customValue
additionalProperties:
type: object
properties:
field: { type: string, enum: [name, phone, email, company, custom] }
customValue: { type: string, description: Static value when field is "custom" }
exitOnReply: { type: boolean, default: true }
exitOnUnsubscribe: { type: boolean, default: true }
responses:
'200':
description: Sequence created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
status: { type: string }
stepsCount: { type: integer }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sequences/{sequenceId}:
get:
x-resource-group: "messages"
operationId: getSequence
summary: Get sequence with steps
description: Returns a sequence with all its steps and enrollment stats.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence details with steps
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
status: { type: string, enum: [draft, active, paused] }
steps:
type: array
items:
type: object
properties:
order: { type: integer }
delayMinutes: { type: integer }
message: { type: object, properties: { text: { type: string } } }
template: { type: object, properties: { name: { type: string }, language: { type: string }, variableMapping: { type: object } } }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
totalEnrolled: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "messages"
operationId: updateSequence
summary: Update sequence
description: Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
description: { type: string }
steps:
type: array
description: Replace the full step list. Only allowed while the sequence is draft or paused.
items:
type: object
required: [order, delayMinutes]
properties:
order: { type: integer }
delayMinutes: { type: integer }
message:
type: object
properties:
text: { type: string }
template:
type: object
properties:
name: { type: string }
language: { type: string }
variableMapping:
type: object
additionalProperties:
type: object
properties:
field: { type: string, enum: [name, phone, email, company, custom] }
customValue: { type: string }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
responses:
'200':
description: Sequence updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
steps: { type: array, items: { type: object } }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
updatedAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteSequence
summary: Delete sequence
description: Permanently delete a sequence. Active enrollments are stopped.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Sequence deleted }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/activate:
post:
x-resource-group: "messages"
operationId: activateSequence
summary: Activate sequence
description: Start a draft or paused sequence. The sequence must have at least one step.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence activated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/pause:
post:
x-resource-group: "messages"
operationId: pauseSequence
summary: Pause sequence
description: Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence paused
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enroll:
post:
x-resource-group: "messages"
operationId: enrollContacts
summary: Enroll contacts in a sequence
description: Enroll one or more contacts into a sequence. Contacts already enrolled are skipped.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [contactIds]
properties:
contactIds: { type: array, items: { type: string } }
channelIds: { type: array, items: { type: string }, description: Optional. Auto-detected if not provided. }
responses:
'200':
description: Enrollment results
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
enrolled: { type: integer, description: Number of contacts successfully enrolled }
failed: { type: integer, description: 'Number that failed (already enrolled, or no subscribed channel on the sequence platform)' }
results:
type: array
description: Per-contact outcome
items:
type: object
properties:
contactId: { type: string }
success: { type: boolean }
error: { type: string, description: Present when success is false }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enroll/{contactId}:
delete:
x-resource-group: "messages"
operationId: unenrollContact
summary: Unenroll contact
description: Remove a contact from a sequence. No further messages will be sent to this contact.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Contact unenrolled }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enrollments:
get:
x-resource-group: "messages"
operationId: listSequenceEnrollments
summary: List enrollments for a sequence
description: Returns enrolled contacts with their progress, status, and next scheduled step.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [active, completed, exited, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Enrollments list with progress
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
enrollments:
type: array
items:
type: object
properties:
id: { type: string }
contactId: { type: string }
channelId: { type: string }
platformIdentifier: { type: string }
contactName: { type: string }
currentStepIndex: { type: integer }
status: { type: string, enum: [active, completed, exited, paused] }
exitReason: { type: [string, "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' }
post:
x-resource-group: "messages"
operationId: createCommentAutomation
tags: [Comment Automations]
summary: Create comment-to-DM automation
description: |
Create a keyword-triggered DM automation on an Instagram or Facebook account.
When someone comments a matching keyword (or, with `trigger: story_reply`, replies
to your Instagram story with one), they automatically receive a DM.
Triggers (`trigger`):
* `comment` (default): fires on keyword comments on a post or reel.
* `story_reply`: fires when someone replies to your Instagram story with a keyword,
and answers them with a DM. Set `platformPostId` to a story media id to scope to
one story, or omit it to match replies to any story.
Targeting (comment trigger):
* Per-post: set `platformPostId` to scope to one specific post (only one active
per-post automation is allowed per post).
* Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation
evaluates every comment on every post on the account. You can stack unlimited
account-wide automations, each with its own keyword set, and they all run
independently. Per-post automations take priority on their post.
Audience (`audience`, Instagram only): restrict the automation to followers or
non-followers, and/or to accounts above a follower count. Instagram only reveals the
follow relationship for people who have messaged the account, so `audience.whenUnknown`
decides what happens for everyone else - including `verify`, which sends a one-tap
confirmation DM (`followGate`) and then delivers the real DM automatically. People we
already know follow you skip the tap entirely.
Set `alsoMatchInDms: true` on a `comment` automation to also answer people who send
a keyword as a direct message instead of commenting it. One automation then covers
both doors, and each door is deduplicated separately (someone who already got the DM
from their comment still gets it if they later DM the keyword). Requires at least one
keyword.
Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and
clickers optionally tagged (`clickTag`) for segmentation. Stats returned include
delivered, read, and link clicks.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, name, dmMessage]
properties:
profileId: { type: string }
accountId: { type: string, description: Instagram or Facebook account ID }
trigger: { type: string, enum: [comment, story_reply], default: comment, description: "What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story)." }
platformPostId: { type: string, description: "Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation." }
postId: { type: string, description: "Zernio post ID. Required only when also targeting a specific post via platformPostId." }
postTitle: { type: string, description: Post content snippet for display }
name: { type: string, description: Automation label }
keywords:
type: array
items: { type: string }
description: Trigger keywords (empty = any comment triggers)
matchMode: { type: string, enum: [exact, contains, word], default: contains, description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string, description: "DM text to send to commenter. Max 640 chars when buttons are set, otherwise ~1000." }
buttons:
type: array
maxItems: 3
items: { $ref: '#/components/schemas/DmButton' }
description: "Optional inline DM buttons (1-3). Phone buttons are Facebook-only. Omit or pass [] for a plain-text DM."
template:
allOf: [{ $ref: '#/components/schemas/CommentAutomationTemplate' }]
description: "Optional product card sent INSTEAD of the plain dmMessage bubble. Mutually exclusive with buttons. dmMessage stays required: it is what gets sent the moment the card is cleared."
commentReply: { type: string, description: Optional public reply to the comment }
dmMessageVariations:
type: array
maxItems: 5
items: { type: string }
description: "Optional alternate DM texts for random rotation. When set, each triggered comment sends one picked at random from [dmMessage, ...dmMessageVariations], so repeat commenters get slightly different DMs (helps avoid identical-message patterns). Up to 5. Buttons are attached to whichever text is picked, not varied."
commentReplyVariations:
type: array
maxItems: 5
items: { type: string }
description: "Optional alternate public replies, rotated at random alongside commentReply (picked independently of the DM). Up to 5."
linkTracking: { type: boolean, default: true, description: "Wrap link buttons in the DM in a tracked redirect so clicks are counted (Link Clicks / CTR). Pass false to send links exactly as written. Defaults to on." }
clickTag: { type: string, description: "Optional tag applied to a contact when they click a tracked link (requires linkTracking). Lets you segment clickers for broadcasts/sequences." }
dmDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait after the trigger before sending the DM. Omit or send 0 to reply immediately (the default). Max 86400 (24h). The trigger is still matched and deduplicated the moment the comment arrives, so a delay only moves when the response is sent." }
commentReplyDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait before posting the public comment reply. Omit or send 0 to post it right after the DM (the default). The reply never goes out before the DM, so a value below dmDelaySeconds is raised to it. Ignored when trigger=story_reply, which has no public reply." }
alsoMatchInDms: { type: boolean, default: false, description: "Also fire these keywords on a plain inbound DM, so the automation answers people who message the keyword instead of commenting it. Requires at least one keyword (an empty keyword list means 'match anything', which would answer every inbound message) and is rejected on story_reply automations, which already trigger on DMs. Dedup is per door: a contact who already received the DM from their comment can still receive it from a DM." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
responses:
'200':
description: Automation created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string }
trigger: { type: string, enum: [comment, story_reply] }
platformPostId: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
items: { $ref: '#/components/schemas/DmButton' }
description: Inline DM buttons (up to 3). Omitted when none are set.
template:
$ref: '#/components/schemas/CommentAutomationTemplate'
commentReply: { type: string }
dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
linkTracking: { type: boolean }
clickTag: { type: string }
dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
isActive: { type: boolean }
stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
createdAt: { type: string, format: date-time }
'400': { description: Validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: "Active per-post automation already exists for this platformPostId. Does not apply to account-wide automations." }
/v1/comment-automations/{automationId}:
get:
x-resource-group: "messages"
operationId: getCommentAutomation
tags: [Comment Automations]
summary: Get automation details
description: Returns an automation with its configuration, stats, and recent trigger logs.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Automation details with stats and recent trigger logs
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string }
trigger: { type: string, enum: [comment, story_reply] }
accountId: { type: string }
platformPostId: { type: string }
postId: { type: string }
postTitle: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
items: { $ref: '#/components/schemas/DmButton' }
description: Inline DM buttons (up to 3). Omitted when none are set.
template:
$ref: '#/components/schemas/CommentAutomationTemplate'
commentReply: { type: string }
dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
linkTracking: { type: boolean }
clickTag: { type: string }
dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
isActive: { type: boolean }
stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
logs:
type: array
items:
type: object
properties:
id: { type: string }
commentId: { type: string }
commenterId: { type: string }
commenterName: { type: string }
commentText: { type: string }
source: { type: string, enum: [comment, story_reply, dm], description: "Which door triggered this send. Absent on rows written before this field existed (all of those are comment-triggered)." }
status: { type: string, enum: [pending, sent, failed, skipped, gated], description: "DM outcome. 'pending' = the automation has a dmDelaySeconds and the response is queued but not sent yet. 'gated' = the follow-gate confirmation DM went out and we are waiting for the tap; it flips to 'sent' or 'skipped' when they tap." }
audienceOutcome: { type: string, enum: [passed, blocked, gate_sent, gate_passed, gate_failed], description: "How the audience rule resolved. Absent on automations without one." }
commenterIsFollower: { type: boolean, description: "Follow relationship at decision time. Absent when Instagram would not tell us (the commenter never messaged the account)." }
commenterFollowerCount: { type: integer }
error: { type: string, description: DM error message if status is failed }
commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
nextDueAt: { type: string, format: date-time, description: "When the next queued send fires. Present only while something is still pending." }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "messages"
operationId: updateCommentAutomation
tags: [Comment Automations]
summary: Update automation settings
description: |
Update an automation's keywords, DM message, inline buttons, comment reply, or active status.
Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new
one if you're changing it, otherwise the stored one) must be 640 characters or less.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
trigger: { type: string, enum: [comment, story_reply], description: "What fires the automation. Changing it detaches the automation from its bound post or story (a post id and a story id are different objects), unless this same request sets a new binding. 'story_reply' is Instagram only." }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
maxItems: 3
items: { $ref: '#/components/schemas/DmButton' }
description: "Inline DM buttons (1-3). Pass [] to clear all buttons."
template:
oneOf:
- $ref: '#/components/schemas/CommentAutomationTemplate'
- type: 'null'
description: "Product card sent instead of the plain dmMessage bubble. Pass null to clear it and fall back to dmMessage. Mutually exclusive with buttons, including with the buttons already stored on the automation."
commentReply: { type: string }
dmMessageVariations:
type: array
maxItems: 5
items: { type: string }
description: "Alternate DM texts for random rotation (see create). Pass [] to clear."
commentReplyVariations:
type: array
maxItems: 5
items: { type: string }
description: "Alternate public replies for random rotation. Pass [] to clear."
linkTracking: { type: boolean, description: "Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched." }
clickTag: { type: string, description: "Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it." }
alsoMatchInDms: { type: boolean, description: "Also fire these keywords on a plain inbound DM. Enabling it requires the automation to end up with at least one keyword (this request's keywords if you send them, otherwise the stored ones) and is rejected on story_reply automations." }
dmDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait after the trigger before sending the DM. Send 0 to clear the delay and reply immediately." }
commentReplyDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait before posting the public comment reply. Send 0 to clear it. The reply never goes out before the DM." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
isActive: { type: boolean }
responses:
'200':
description: Automation updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
items: { $ref: '#/components/schemas/DmButton' }
description: Inline DM buttons (up to 3). Omitted when none are set.
template:
$ref: '#/components/schemas/CommentAutomationTemplate'
commentReply: { type: string }
dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
isActive: { type: boolean }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteCommentAutomation
tags: [Comment Automations]
summary: Delete automation
description: Permanently delete an automation and all its trigger logs.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Automation deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/comment-automations/{automationId}/logs:
get:
x-resource-group: "messages"
operationId: listCommentAutomationLogs
tags: [Comment Automations]
summary: List automation logs
description: Paginated list of every comment that triggered this automation, with send status and commenter info.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [pending, sent, failed, skipped, gated] }, description: Filter by result status }
- { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
- { name: skip, in: query, schema: { type: integer, default: 0, minimum: 0 } }
responses:
'200':
description: Trigger logs with pagination
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
logs:
type: array
items:
type: object
properties:
id: { type: string }
commentId: { type: string }
commenterId: { type: string }
commenterName: { type: string }
commentText: { type: string }
source: { type: string, enum: [comment, story_reply, dm], description: "Which door triggered this send. Absent on rows written before this field existed (all of those are comment-triggered)." }
status: { type: string, enum: [pending, sent, failed, skipped, gated], description: "DM outcome. 'pending' = the automation has a dmDelaySeconds and the response is queued but not sent yet. 'gated' = the follow-gate confirmation DM went out and we are waiting for the tap; it flips to 'sent' or 'skipped' when they tap." }
audienceOutcome: { type: string, enum: [passed, blocked, gate_sent, gate_passed, gate_failed], description: "How the audience rule resolved. Absent on automations without one." }
commenterIsFollower: { type: boolean, description: "Follow relationship at decision time. Absent when Instagram would not tell us (the commenter never messaged the account)." }
commenterFollowerCount: { type: integer }
error: { type: string, description: DM error message if status is failed }
commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
nextDueAt: { type: string, format: date-time, description: "When the next queued send fires. Present only while something is still pending." }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
misses:
type: object
description: "Comments that reached this automation but matched none of its keywords. These produce no log entry, so this is the only signal that a keyword is catching nothing. Retained for a short window, then dropped."
properties:
total: { type: integer, description: Number of non-matching comments in the retention window }
retentionDays: { type: integer, description: How many days of non-matching comments the total covers }
samples:
type: array
description: A few of the most recent non-matching comments, for diagnosing a keyword setup.
items:
type: object
properties:
commentText: { type: string }
commenterName: { type: string }
excludedBy: { type: string, description: Set when an exclusion keyword vetoed an otherwise matching comment }
at: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ── Ads ──────────────────────────────────────────────────────────────────
/v1/ads:
get:
x-resource-group: "ads"
operationId: listAds
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List ads
description: |
Returns a paginated list of ads with metrics computed over an optional date range.
Use source=all to include externally-synced ads from platform ad managers.
If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.
To find the Zernio ad behind a comment you see in Meta Business Manager, filter by
platformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or
effectiveInstagramMediaId (Instagram) — those are the post/media the ad's engagement
lives on, and are also returned on each ad's `creative` object. Then call
GET /v1/ads/{adId}/comments with the returned ad id.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only." }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' } }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] } }
- { name: accountId, in: query, schema: { type: string }, description: Social account ID }
- { name: adAccountId, in: query, schema: { type: string }, description: "Platform ad account ID (e.g. act_123 for Meta). Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree." }
- { name: pageId, in: query, schema: { type: string }, description: "Meta only: Facebook Page ID. Returns only ads whose creative is backed by this Page (a Meta ad account serves ads for every Page in the Business Manager). Matches each ad's `creative.pageId`; ads with no page signal (rare IG-only creatives) never match. Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree." }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: campaignId, in: query, schema: { type: string }, description: Platform campaign ID (filter ads within a campaign) }
- { name: platformAdId, in: query, schema: { type: string }, description: "Meta ad ID. Returns the ad with this platform-side ad ID." }
- { name: effectiveObjectStoryId, in: query, schema: { type: string }, description: "Facebook `{pageId}_{postId}` of the post the ad's engagement lives on (Meta `effective_object_story_id`). Use to map a Business-Manager-visible post back to the Zernio ad." }
- { name: effectiveInstagramMediaId, in: query, schema: { type: string }, description: "Instagram media ID of the boosted post (Meta `effective_instagram_media_id`). Use to map a Business-Manager-visible IG post back to the Zernio ad." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of metrics date range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
responses:
'200':
description: Paginated ads
content:
application/json:
schema: { $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` live; defaults
to the last 30 days. Rows are ordered by cost, descending. 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: "Defaults to 30 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "Defaults to today." }
- { name: campaignId, in: query, schema: { type: string }, description: "Numeric Google campaign id filter." }
- { name: adGroupId, in: query, schema: { type: string }, description: "Numeric Google ad group id filter." }
- { name: pageToken, in: query, schema: { type: string }, description: "Cursor from paging.nextPageToken of the previous page." }
responses:
'200':
description: Search terms
content:
application/json:
schema:
type: object
properties:
customerId: { type: string }
data:
type: array
items:
type: object
properties:
searchTerm: { type: [string, "null"] }
status: { type: [string, "null"], description: "ADDED / EXCLUDED / ADDED_EXCLUDED / NONE — whether the term is already a keyword or a negative." }
matchType: { type: [string, "null"], description: "How the term matched (BROAD, PHRASE, EXACT, NEAR_PHRASE, NEAR_EXACT)." }
campaignId: { type: [string, "null"] }
campaignName: { type: [string, "null"] }
adGroupId: { type: [string, "null"] }
adGroupName: { type: [string, "null"] }
impressions: { type: integer }
clicks: { type: integer }
costMicros: { type: integer, description: "Cost in micros of the account currency (divide by 1,000,000)." }
conversions: { type: number }
conversionsValue: { type: number }
paging:
type: object
properties:
nextPageToken: { type: [string, "null"], description: "Null when the last page was returned." }
'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:
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:
'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: Conversations of a Local Services lead
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:
'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: Social account ID }
- { name: adAccountId, in: query, schema: { type: string }, description: 'Platform ad account ID (Google customer ID). Mirrors the same filter on /v1/ads.' }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: campaignId, in: query, schema: { type: string }, description: Platform campaign ID }
- { name: adSetId, in: query, schema: { type: string }, description: 'Platform ad group ID (Google ad group)' }
- { name: status, in: query, schema: { type: string, enum: [active, paused] }, description: Keyword criterion status }
- { name: matchType, in: query, schema: { type: string, enum: [exact, phrase, broad, unknown] } }
- { name: negative, in: query, schema: { type: boolean }, description: 'true = negative keywords only, false = positive only. Omit for both.' }
- { name: search, in: query, schema: { type: string, maxLength: 200 }, description: 'Case-insensitive substring match on the keyword text' }
responses:
'200':
description: Paginated keywords
content:
application/json:
schema:
type: object
properties:
keywords:
type: array
items:
type: object
properties:
id: { type: string }
accountId: { type: string, description: Social account ID owning the sync }
profileId: { type: string }
platform: { type: string, enum: [google] }
adAccountId: { type: string, description: Google customer ID }
campaignId: { type: string }
campaignName: { type: [string, "null"] }
campaignStatus: { type: [string, "null"] }
adSetId: { type: string, description: Google ad group ID }
adSetName: { type: [string, "null"] }
adSetStatus: { type: [string, "null"] }
keyword: { type: string }
matchType: { type: string, enum: [exact, phrase, broad, unknown] }
status: { type: string, enum: [active, paused] }
negative: { type: boolean }
syncedAt: { type: [string, "null"], format: date-time }
pagination: { $ref: '#/components/schemas/Pagination' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/campaigns:
get:
x-resource-group: "ads"
operationId: listAdCampaigns
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List campaigns
description: |
Returns campaigns as virtual aggregations over ad documents grouped by platform campaign ID.
Metrics (spend, impressions, clicks, etc.) are summed across all ads in each campaign.
Campaign status is derived from child ad statuses (active > pending_review > paused > error > completed > cancelled > rejected).
security:
- bearerAuth: []
parameters:
- name: includeEmpty
in: query
required: false
schema: { type: boolean }
description: >-
Meta only. Campaign reads aggregate over ad documents, so a campaign
with ZERO ads is normally invisible here — the state the two-step
create (campaign, then ads via `existingCampaignId`) leaves behind
whenever Meta rejects the ad step. Set true to list those too, with
`adCount: 0` and zeroed metrics. Requires `accountId` and
`adAccountId`, since an empty campaign has no ad row to resolve a
token or ad account from.
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that." }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
- { name: adAccountId, in: query, schema: { type: string }, description: Platform ad account ID (e.g. act_123 for Meta) }
- { name: pageId, in: query, schema: { type: string }, description: "Meta only: Facebook Page ID. Campaigns have no Page of their own, so this keeps campaigns having at least one ad backed by this Page, with adCount and metrics computed over those ads only. Mirrors the same filter on /v1/ads and /v1/ads/tree." }
- { name: accountId, in: query, schema: { type: string }, description: Social account ID }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of metrics date range (YYYY-MM-DD, inclusive). Defaults to 90 days ago when both date params are omitted." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of metrics date range (YYYY-MM-DD, inclusive). Defaults to today. Max 730-day range." }
- { name: hasDelivery, in: query, schema: { type: boolean }, description: "Return only campaigns that delivered between `fromDate` and `toDate` — spend above zero, or impressions served at zero spend. Unlike `status`, which reads a campaign's CURRENT state, this filters on what happened inside the window. Filters the campaign set itself, so `pagination.total` counts only matching campaigns. Mirrors the same filter on /v1/ads/tree." }
- { name: minSpend, in: query, schema: { type: number, minimum: 0 }, description: "Return only campaigns whose spend between `fromDate` and `toDate` reaches this amount, in each campaign's OWN currency (the `currency` field on the campaign). Implies `hasDelivery`; `minSpend=0` applies no filter. Mirrors the same filter on /v1/ads/tree." }
responses:
'200':
description: Paginated campaigns
content:
application/json:
schema:
type: object
properties:
campaigns:
type: array
items: { $ref: '#/components/schemas/AdCampaign' }
pagination: { $ref: '#/components/schemas/Pagination' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
post:
x-resource-group: "ads"
operationId: createAdCampaign
tags: ["Ad Campaigns"]
x-platforms: ["meta"]
summary: Create a standalone campaign
description: |-
Creates a campaign WITHOUT its first ad set / ad (the ODAX shell only). Ad sets join it
later via `existingCampaignId` on the create endpoints. A budget here is campaign-level
(CBO) by definition; omit it for ABO (each ad set carries its own budget). Created
`PAUSED` unless `status: ACTIVE`. The campaign materializes in `/v1/ads/tree` via the
next sync discovery pass.
**Idempotency:** send an `Idempotency-Key` header to make retries safe.
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, goal]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
name: { type: string, maxLength: 255 }
goal:
type: string
enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, job_applicants, conversions, app_promotion, catalog_sales, page_likes]
description: Mapped to the ODAX objective (same mapping as POST /v1/ads/create).
specialAdCategories:
type: array
items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS, FINANCIAL_PRODUCTS_SERVICES, ONLINE_GAMBLING_AND_GAMING] }
budgetAmount: { type: number, description: "Campaign-level (CBO) budget in WHOLE currency units (USD: 50 = $50.00), NOT cents — Meta's own Marketing API takes this same number in minor units, so it is an easy and expensive mix-up. Requires budgetType." }
budgetType: { type: string, enum: [daily, lifetime] }
status: { type: string, enum: [ACTIVE, PAUSED], default: PAUSED }
bidStrategy:
type: string
enum: [LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS]
description: "Campaign bid strategy. Meta stores `bid_strategy` alongside the budget, so this REQUIRES `budgetAmount` + `budgetType` on the same request; sending it without a campaign budget is a 400. A campaign carrying a strategy without its `bid_amount` makes every ad set created under it fail with an error that names the ad set (code 100, subcode 1815857), so the bad state is rejected up front rather than accepted. To bid at ad-set level, set the strategy there instead."
bidAmount: { type: number, description: "Whole currency units (USD: 5 = $5.00). Required for LOWEST_COST_WITH_BID_CAP and COST_CAP; ignored otherwise. Validated here but NOT stored by Meta: the campaign object has no bid_amount field, only bid_strategy lives on it. The amount takes effect once an ad set joins this campaign (existingCampaignId on POST /v1/ads/create) and supplies its own bidAmount there." }
roasAverageFloor: { type: number, description: "Decimal ROAS multiplier (2.0 = 2.0x). Required for LOWEST_COST_WITH_MIN_ROAS." }
responses:
'201':
description: Campaign created
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
campaignId: { type: string, description: Platform id of the new campaign }
objective: { type: string, description: "Resolved ODAX objective (e.g. OUTCOME_SALES)." }
status: { type: string, enum: [ACTIVE, PAUSED] }
'400': { description: "Invalid input, or Meta rejected the create" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/campaigns/{campaignId}/status:
put:
x-resource-group: "ads"
operationId: updateAdCampaignStatus
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x", "openai"]
summary: Pause or resume a campaign
description: |
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.
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 social accounts
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: No ads found for this campaign
/v1/ads/campaigns/{campaignId}:
put:
x-resource-group: "ads"
operationId: updateAdCampaign
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google"]
summary: Update a campaign
description: |
Campaign-level edits. Send at least one of `budget`, `bidStrategy`, `name`
or `platformSpecificData`. An unsupported field is always an error, never a
silent drop.
| Body field | Meta | Google | Others |
|---|---|---|---|
| `bidStrategy` | Yes | Yes | 501 |
| `bidAmount`, `roasAverageFloor` | 400 — ad-set level | Yes | 400 |
| `budget` (CBO; ABO returns 409) | Yes | 501 | 501 |
| `name` | Yes | 501 | 501 |
| `platformSpecificData.spendCap` | Yes | 400 | 400 |
| `accountId` (empty campaigns) | Yes | - | - |
Google maps the shared enum onto its own strategies: `LOWEST_COST_WITHOUT_CAP`
to Maximize Clicks, `LOWEST_COST_WITH_BID_CAP` to Maximize Clicks with a max
CPC (`bidAmount`), `COST_CAP` to Target CPA (`bidAmount`),
`LOWEST_COST_WITH_MIN_ROAS` to Target ROAS (`roasAverageFloor`). A campaign on
a PORTFOLIO bidding strategy is rejected: detach it in Google Ads first, since
it is shared across campaigns.
`accountId` forwards the update straight to Meta for a campaign with zero ads,
which would otherwise 404; the response then carries `updated: 0`.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, google], description: "Required: platform campaign IDs are not globally unique." }
accountId: { type: string, description: "**Meta only.** Zernio SocialAccount id owning the ad account. Needed only for an EMPTY campaign (zero ads); ignored otherwise." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: "**Meta + Google.** On Meta, the campaign default that ad sets inherit unless they override it. On Google, the campaign's own bidding strategy."
bidAmount: { type: number, description: "**Google only.** Whole currency units (USD: 12 = $12.00). Max CPC for LOWEST_COST_WITH_BID_CAP, CPA target for COST_CAP; required for both." }
roasAverageFloor: { type: number, description: "**Google only.** Decimal ROAS multiplier (2.0 = 2.0x), required for LOWEST_COST_WITH_MIN_ROAS." }
budget:
type: object
description: "**Meta only.** The CBO budget."
required: [amount, type]
properties:
amount: { type: number, description: Budget amount in the ad account's currency }
type: { type: string, enum: [daily, lifetime] }
name: { type: string, maxLength: 255, description: "**Meta only.** Rename the campaign." }
platformSpecificData:
type: object
description: "**Meta only.** Platform implied by the `platform` body param, same convention as POST /v1/ads/create."
properties:
spendCap: { type: [number, "null"], description: "Campaign lifetime spend cap, in the ad account's currency (Meta `spend_cap`). Pass null to remove the cap; 0 is rejected by Meta." }
responses:
'200':
description: Campaign updated
content:
application/json:
schema:
type: object
description: Echoes back only the fields you sent, plus `updated`.
properties:
updated: { type: integer, description: "Local Ad documents mirrored. 0 on the empty-campaign path." }
budget: { $ref: '#/components/schemas/AdBudget' }
budgetLevel: { type: string, enum: [campaign] }
bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
bidAmount: { type: number }
roasAverageFloor: { type: number }
platformSpecificData: { type: object }
'400': { description: "Invalid input, or a field the resolved platform does not support at the campaign level (see the support table)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Campaign not found }
'409': { description: "Campaign is ABO — route to /v1/ads/ad-sets/{adSetId} instead" }
'501': { description: Operation not supported on this platform }
delete:
x-resource-group: "ads"
operationId: deleteAdCampaign
tags: ["Ad Campaigns"]
x-platforms: ["meta", "tiktok", "google"]
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:
'200':
description: Campaign deleted
content:
application/json:
schema:
type: object
properties:
deleted: { type: boolean }
adCount: { type: integer, description: Number of local Ad docs marked cancelled }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Campaign not found }
'501': { description: Operation not supported on this platform }
/v1/ads/campaigns/bulk-status:
post:
x-resource-group: "ads"
operationId: bulkUpdateAdCampaignStatus
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Pause or resume many campaigns
description: |
Process up to 50 campaigns in one call. Each campaign is updated
concurrently and the response contains a per-campaign result so a
single bad row does not fail the whole batch.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, campaigns]
properties:
status: { type: string, enum: [active, paused] }
campaigns:
type: array
maxItems: 50
items:
type: object
required: [platformCampaignId, platform]
properties:
platformCampaignId: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
responses:
'200':
description: Per-campaign results
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [active, paused] }
totals:
type: object
properties:
updated: { type: integer }
skipped: { type: integer }
failed: { type: integer }
results:
type: array
items:
type: object
properties:
platformCampaignId: { type: string }
platform: { type: string }
updated: { type: integer }
skipped: { type: integer }
error: { type: string }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/campaigns/{campaignId}/duplicate:
post:
x-resource-group: "ads"
operationId: duplicateAdCampaign
tags: ["Ad Campaigns"]
x-platforms: ["meta", "tiktok", "linkedin"]
summary: Duplicate a campaign
description: |
Duplicates a campaign, including its ad sets, ads, creatives, and
targeting by default (`deepCopy: true`). The copy is created paused
so callers can review before launching.
Per-platform implementation:
- **Meta** uses the native `POST /{campaign-id}/copies` endpoint.
- **TikTok** has no native copy primitive; Zernio walks the source
graph (`/v2/campaign/get/`, `/v2/adgroup/get/`, `/v2/ad/get/`) and
recreates each entity via the corresponding `/create/` endpoints,
carrying over budget / targeting / bid_type / bid_price /
deep_bid_type / creative fields. Spark Ad linkage (`tiktok_item_id`)
is preserved.
- **LinkedIn** has no native copy primitive; Zernio walks the source
CampaignGroup → Campaigns → Creatives and recreates each entity,
carrying over `type` / `costType` / `unitCost` /
`optimizationTargetType` / `creativeSelection` / `objectiveType` /
`format` / `dailyBudget` / `totalBudget` / `targetingCriteria` /
`runSchedule` and every Creative's `content` object verbatim.
`statusOption: INHERITED_FROM_SOURCE` is evaluated **per entity**:
any Group / Campaign / Creative whose source is `ACTIVE` gets its
clone activated too. Duplicating an ACTIVE campaign with
`INHERITED_FROM_SOURCE` starts a second front of spend the moment
the clone activates — the safe default is `PAUSED`.
The new hierarchy is asynchronous to materialize in our DB — we
trigger sync discovery automatically. Set `syncAfter: false` to
skip and poll `/v1/ads/tree` on your own cadence.
Other platforms return 501 Not Implemented.
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Source platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin] }
deepCopy: { type: boolean, default: true, description: Copy child ad sets + ads + creatives + targeting }
statusOption:
type: string
enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE]
default: PAUSED
description: >
ACTIVE = launch the clone immediately (spends the moment
LinkedIn approves it). PAUSED = clone stays DRAFT, safe
default. INHERITED_FROM_SOURCE = mirror each entity's
source status per-entity. Duplicating an ACTIVE
campaign this way starts a second front of spend.
startTime: { type: string, format: date-time, description: Reschedule the copied hierarchy's start time }
endTime: { type: string, format: date-time }
renameStrategy:
type: string
enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME]
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true, description: Trigger ads discovery on the owning account after the copy succeeds }
responses:
'200':
description: Campaign duplicated
content:
application/json:
schema:
type: object
properties:
copiedCampaignId: { type: string, description: Platform ID of the new campaign }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw:
type: object
description: Platform-native response from the copy endpoint (Meta includes ad_object_ids for child copies)
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Source campaign not found }
'501': { description: Operation not supported on this platform }
/v1/ads/ad-sets/{adSetId}/duplicate:
post:
x-resource-group: "ads"
operationId: duplicateAdSet
tags: ["Ad Campaigns"]
x-platforms: ["meta"]
summary: Duplicate an ad set
description: |-
Duplicates an ad set, including its ads and creatives by default (`deepCopy: true`),
via Meta's native `POST /{adset-id}/copies`. The copy is created paused so callers can
review before launching. `campaignId` retargets the copy into another campaign; omitted
= the source's own campaign. The new hierarchy materializes asynchronously — sync
discovery is triggered automatically (`syncAfter: false` to skip).
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Source platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram] }
campaignId: { type: string, description: Destination platform campaign id (defaults to the source's campaign) }
deepCopy: { type: boolean, default: true, description: Copy child ads + creatives }
statusOption: { type: string, enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE], default: PAUSED }
startTime: { type: string, format: date-time, description: Reschedule the copy's start time }
endTime: { type: string, format: date-time }
renameStrategy: { type: string, enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME] }
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true }
responses:
'200':
description: Ad set duplicated
content:
application/json:
schema:
type: object
properties:
copiedAdSetId: { type: string, description: Platform ID of the new ad set }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw: { type: object, description: Meta's native copy response (includes ad_object_ids for child copies) }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Source ad set not found }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{adId}/duplicate:
post:
x-resource-group: "ads"
operationId: duplicateAd
tags: ["Ad Campaigns"]
x-platforms: ["meta"]
summary: Duplicate an ad
description: |-
Duplicates a single ad via Meta's native `POST /{ad-id}/copies`. The copy is created
paused. `adSetId` retargets the copy into another ad set; omitted = the source's own ad
set. Accepts the Zernio ad id or the platform ad id. Sync discovery is triggered
automatically (`syncAfter: false` to skip).
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
- { name: adId, in: path, required: true, schema: { type: string }, description: Zernio ad ID or platform ad ID }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
adSetId: { type: string, description: Destination platform ad set id (defaults to the source's ad set) }
statusOption: { type: string, enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE], default: PAUSED }
renameStrategy: { type: string, enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME] }
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true }
responses:
'200':
description: Ad duplicated
content:
application/json:
schema:
type: object
properties:
copiedAdId: { type: string, description: Platform ID of the new ad }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw: { type: object }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Ad not found }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/ad-sets/{adSetId}:
get:
x-resource-group: "ads"
operationId: getAdSetDetails
tags: ["Ad Campaigns"]
x-platforms: ["meta"]
summary: Live ad-set details incl. learning phase
description: |-
Reads the ad set live from Meta, returned verbatim. The default projection includes
`learning_stage_info` (learning-phase status: LEARNING / SUCCESS / FAIL / WAIVING — Meta
omits its `status` key on paused ad sets), delivery settings, budgets, schedule and
targeting. `fields` is a raw-passthrough override; unknown fields return Meta's 400
verbatim.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: "Meta ad set id (platformAdSetId)." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
responses:
'200':
description: The ad set as returned by Meta
content:
application/json:
schema:
type: object
properties:
adSet: { type: object, description: "Raw Meta ad set; keys are the requested Graph fields." }
'400': { description: "Invalid input, or Meta rejected the query — message carries Meta's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
put:
x-resource-group: "ads"
operationId: updateAdSet
tags: ["Ad Campaigns"]
x-platforms: ["meta", "tiktok", "openai"]
summary: Update an ad set
description: |
Ad-set-level writes. Use this for ABO budget updates, ad-set-scoped
pause/resume, bid-strategy edits, Meta value-rule-set attach/detach, and
Meta-only post-launch delivery settings via `platformSpecificData`. At
least one updatable field is required.
Value rule sets (Meta only, see `/v1/ads/value-rule-sets`):
- ATTACH or REPLACE: send `valueRuleSetId`. Attachment is driven by the id's
presence, so `valueRulesApplied: true` is optional. Sending a different id
replaces the previous association; there is no separate replace call.
- DETACH: send `valueRulesApplied: false` and OMIT `valueRuleSetId`.
- Sending `valueRulesApplied: false` TOGETHER with `valueRuleSetId` returns 400
`mutually_exclusive_fields`. This is deliberate: Meta attaches the rule set
whenever `value_rule_set_id` is present, even with `value_rules_applied` false,
so echoing stored state while asking to detach would silently keep the bid
adjustments live.
- Eligibility: only ad sets on `LOWEST_COST_WITHOUT_CAP` or `COST_CAP`. Meta
rejects the rest server-side.
- Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`. Meta
does not document `value_rules_applied` as a readable ad-set field, so the
boolean cannot be read back.
Bid strategy compatibility (per Meta's spec):
- `LOWEST_COST_WITHOUT_CAP`: no `bidAmount`, no `roasAverageFloor`.
- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: `bidAmount` REQUIRED (whole currency units).
- `LOWEST_COST_WITH_MIN_ROAS`: `roasAverageFloor` REQUIRED (decimal multiplier, e.g. 2.0 = 2.0x ROAS).
- 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)." }
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' }
'404': { description: Ad set not found }
'409': { description: "Campaign is CBO — route to /v1/ads/campaigns/{campaignId} instead" }
'422': { description: "bidStrategy is LOWEST_COST_WITH_MIN_ROAS on OpenAI (unsupported: no ROAS-based bidding)" }
'501': { description: "bidStrategy not supported on the platform (Meta, TikTok, and OpenAI only)" }
/v1/ads/ad-sets/{adSetId}/status:
put:
x-resource-group: "ads"
operationId: updateAdSetStatus
tags: ["Ad Campaigns"]
x-platforms: ["meta", "tiktok"]
summary: Pause or resume a single ad set
description: |
Ad-set-scoped pause/resume (doesn't touch sibling ad sets). Thin wrapper
over PUT /v1/ads/ad-sets/{adSetId} for callers that only want the
status toggle and prefer a symmetric URL to
/v1/ads/campaigns/{campaignId}/status.
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' }
'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 workspace 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: Social account ID }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: campaignId, in: query, schema: { type: string }, description: "Restrict the tree to a single campaign by its platform campaign id (the id the platform assigns, e.g. Meta's numeric campaign id). Filters the campaign set itself, so it works regardless of account size and pagination — pass this when you already hold a campaign id instead of paging the tree to find it. Mirrors the `campaignId` filter on GET /v1/ads." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of the METRICS date range (YYYY-MM-DD). 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) — same convention as `/v1/ads/tree` and `/v1/ads`.
Date range defaults to the last 90 days. Capped at 730 days. Ranges older
than the ingested history return a `202` immediately with the covered part
and `backfillPending: true` while the rest is backfilled in the background;
repeat the request shortly until it returns 200 with full data.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID. Sibling-expanded to its linked posting↔ads pair. }
- { name: adAccountId, in: query, schema: { type: string }, description: "Optional platform-native ad account ID (e.g. Meta `act_…`, TikTok advertiser ID). Use when the connection wraps multiple platform ad accounts and the chart should show one only. Note: rows ingested before 2026-05-13 don't carry this column; the recurring 7-day re-sync repopulates them naturally." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Inclusive start of metrics range (YYYY-MM-DD). Defaults to 90 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "Inclusive end of metrics range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }, description: Restrict to one platform. }
responses:
'200':
description: Daily time series of aggregate metrics. Empty `rows` means the account has no ad activity in the range.
content:
application/json:
schema: { $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.
The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
- the Zernio internal `_id` (24-char hex)
- Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`)
- the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side)
- the creative's `effective_instagram_media_id` (Instagram side)
Any of the four resolve to the same ad. Caller doesn't need a translation step.
security:
- bearerAuth: []
parameters:
- name: adId
in: path
required: true
schema: { type: string }
description: |
Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details.
responses:
'200':
description: Ad details
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "ads"
operationId: updateAd
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Update ad
description: |
Patch one or more fields on an ad. Status, budget, targeting, and creative changes
are propagated to the platform.
Per-platform support:
- **Meta** (Facebook + Instagram): all fields supported.
- **TikTok**: status, budget, targeting (via `/v2/adgroup/update/`), and creative
(via `/v2/ad/update/` patch-style — `headline` is ignored, `body` becomes `ad_text`).
- **Google**: status, budget, and KEYWORD edits via `targeting.keywords` /
`targeting.negativeKeywords` — each list you send becomes the FULL new set of its
kind on the ad group (criteria not in the list are removed); a kind left out is
untouched. Any other `targeting` field returns 400: Google cannot mutate broad
targeting post-create without recreating the campaign. `creative` returns 501.
- **Pinterest / X / LinkedIn / OpenAI Ads**: status + budget only. Sending
`targeting` or `creative` returns 501 with code `unsupported_platform_operation`.
OpenAI Ads budget is lifetime-only (see `budget.type` below).
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [active, paused] }
budget:
type: object
properties:
amount: { type: number, description: "Minimum varies by platform: TikTok=$20, Pinterest=$5, others=$1" }
type: { type: string, enum: [daily, lifetime], description: "OpenAI Ads accepts lifetime only; sending daily returns 422." }
targeting:
type: object
description: |
Meta + TikTok (demographics/interests) and Google (keyword edits only).
Pinterest / X / LinkedIn return 501.
properties:
keywords:
type: array
description: "Google only. The FULL new set of positive keywords for the ad group; live keywords not listed are removed. Entries are strings (BROAD) or { text, matchType } with matchType exact | phrase | broad. 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. Same declarative contract as keywords, for the ad group's negative keywords."
items:
oneOf:
- { type: string }
- { type: object, required: [text], properties: { text: { type: string, maxLength: 80 }, matchType: { type: string, enum: [exact, phrase, broad] } } }
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
countries: { type: array, items: { type: string } }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. Omit to preserve the existing setting on update. 0 = disabled, 1 = enabled." }
creative:
type: object
description: |
Replace the ad's creative. Meta + TikTok only.
- **Meta**: requires `headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`. The
ad's existing creative is replaced via a new `/act_X/adcreatives` upload + ad
update. The old creative is retained on the ad account for historical reporting.
- **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives
have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes
`landing_page_url`; `videoUrl` triggers a fresh upload.
properties:
headline: { type: string, description: "Meta only" }
body: { type: string }
callToAction: { type: string }
linkUrl: { type: string, format: uri }
imageUrl: { type: string, format: uri }
videoUrl: { type: string, format: uri }
name: { type: string, maxLength: 255, description: "Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501." }
responses:
'200':
description: Ad updated
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
'400':
description: Invalid status transition or budget below minimum
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'501': { description: "targeting or creative not supported on the platform (Meta + TikTok only)" }
'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' }
'404': { description: Ad not found }
/v1/ads/campaigns/{campaignId}/assets:
post:
x-resource-group: "ads"
operationId: attachCampaignAssets
tags: ["Ad Campaigns"]
x-platforms: ["google"]
summary: Attach extension assets to a Google Search campaign
description: |-
Attach sitelinks, callouts and/or structured snippets to an already-existing Google
Search campaign — the same builders POST /v1/ads/create uses, but without rebuilding
the hierarchy. At least one of sitelinks, callouts or structuredSnippets is required.
Google-only. Other platforms have no equivalent extension surface and return 501.
Approval status is Google-async; poll `asset.policy_summary` after review. Assets
stay in the account library even if the campaign is later deleted.
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: [accountId]
properties:
accountId: { type: string, description: "Zernio Google Ads SocialAccount id — resolves the customer id + refresh token." }
sitelinks:
type: array
minItems: 2
maxItems: 20
description: "See POST /v1/ads/create sitelinks — same shape."
items:
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 }
callouts:
type: array
minItems: 1
maxItems: 20
items: { type: string, minLength: 1, maxLength: 25 }
structuredSnippets:
type: array
minItems: 1
maxItems: 20
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]
values:
type: array
minItems: 3
maxItems: 10
items: { type: string, minLength: 1, maxLength: 25 }
responses:
'201':
description: Assets 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': { description: "Invalid input, or Google rejected the assets" }
'401': { $ref: '#/components/responses/Unauthorized' }
'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.
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' }
'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' }
/v1/ads/preview:
post:
x-resource-group: "ads"
operationId: generateAdPreviews
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Render pre-create ad previews
description: |
Renders how a creative would look per placement BEFORE any ad exists, via Meta's
`/generatepreviews`. Provide exactly one creative source: `existingCreativeId` or `creativeSpec`.
Each preview is an HTML `<iframe>` snippet embeddable directly. Unknown `formats` values
return Meta's 400 verbatim.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId]
properties:
accountId: { type: string, description: "Zernio SocialAccount id used to resolve the Meta token." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
formats:
type: array
minItems: 1
maxItems: 10
items: { type: string }
description: "Meta ad_format values, one preview per format. Defaults to [DESKTOP_FEED_STANDARD]."
existingCreativeId: { type: string, description: "Preview an existing ad-account creative by id. Mutually exclusive with creativeSpec." }
creativeSpec:
type: object
additionalProperties: true
description: "Raw Meta creative spec forwarded verbatim to /generatepreviews. Mutually exclusive with existingCreativeId."
responses:
'200':
description: Rendered previews
content:
application/json:
schema:
type: object
properties:
previews:
type: array
items:
type: object
properties:
format: { type: string }
html: { type: [string, "null"], description: "Meta's <iframe> snippet; null when Meta returned no preview for the format." }
'400': { description: "Invalid input, or Meta rejected the creative spec / ad_format — message carries Meta's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{adId}/preview:
get:
x-resource-group: "ads"
operationId: getAdPreviews
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Render previews of an existing ad
description: |
Renders an EXISTING ad per placement via Meta's `/{ad_id}/previews`. Each preview is an HTML
`<iframe>` snippet embeddable directly. Unknown `formats` values return Meta's 400 verbatim.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Zernio ad id (24-char hex)." }
- { name: formats, in: query, schema: { type: string }, description: "Comma-separated Meta ad_format values (max 10), one preview per format. Defaults to DESKTOP_FEED_STANDARD." }
responses:
'200':
description: Rendered previews
content:
application/json:
schema:
type: object
properties:
adId: { type: string }
previews:
type: array
items:
type: object
properties:
format: { type: string }
html: { type: [string, "null"], description: "Meta's <iframe> snippet; null when Meta returned no preview for the format." }
'400': { description: "Invalid input, or Meta rejected the ad_format — message carries Meta's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Ad not found }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{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 (Google Keyword Planner)
description: |
Google Ads only. Runs Keyword Planner's generateKeywordIdeas from seed keywords, a seed URL,
or both, returning idea rows verbatim (avgMonthlySearches, competition, competitionIndex,
top-of-page bid micros, monthlySearchVolumes). Counters are int64s encoded as strings; bid
values are micros of the account currency. Omitting `countries` targets worldwide.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: "Zernio googleads SocialAccount id." }
customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
seedKeywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 20, description: "Seed terms. Provide these, seedUrl, or both." }
seedUrl: { type: string, format: uri, description: "Landing page to mine for ideas. Provide this, seedKeywords, or both." }
countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
includeAdultKeywords: { type: boolean }
pageSize: { type: integer, minimum: 1, maximum: 10000 }
pageToken: { type: string, description: "Cursor from paging.nextPageToken of the previous page." }
responses:
'200':
description: Keyword idea rows (raw Keyword Planner shape)
content:
application/json:
schema:
type: object
properties:
customerId: { type: string, description: "The customer the request ran against." }
data:
type: array
items: { type: object, description: "Raw GenerateKeywordIdeaResult: text + keywordIdeaMetrics." }
aggregateMetricResults: { type: [object, "null"] }
paging:
type: object
properties:
nextPageToken: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Google rejected the request — message carries Google's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: "Per-user Google Ads operations budget or the shared Google quota reached; the message says which and when it resets." }
'501': { description: Only supported on Google Ads }
/v1/ads/keywords/historical-metrics:
post:
x-resource-group: "ads"
operationId: generateKeywordHistoricalMetrics
tags: ["Ad Insights"]
x-platforms: ["google"]
summary: Historical keyword metrics (Google Keyword Planner)
description: |
Google Ads only. Runs Keyword Planner's generateKeywordHistoricalMetrics for up to 1,000
exact keywords: historical search volume, competition and top-of-page bid ranges, plus
averageCpcMicros when includeAverageCpc is set. Rows come back verbatim; counters are int64s
encoded as strings, bid/CPC values are micros of the account currency.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, keywords]
properties:
accountId: { type: string, description: "Zernio googleads SocialAccount id." }
customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
keywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 1000 }
countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
includeAdultKeywords: { type: boolean }
includeAverageCpc: { type: boolean, description: "Adds averageCpcMicros to each row's keywordMetrics." }
responses:
'200':
description: Historical metric rows (raw Keyword Planner shape)
content:
application/json:
schema:
type: object
properties:
customerId: { type: string, description: "The customer the request ran against." }
data:
type: array
items: { type: object, description: "Raw KeywordHistoricalMetricsResult: text, closeVariants, keywordMetrics." }
aggregateMetricResults: { type: [object, "null"] }
'400': { description: "Invalid input, or Google rejected the request — message carries Google's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: "Per-user Google Ads operations budget or the shared Google quota reached; the message says which and when it resets." }
'501': { description: Only supported on Google Ads }
/v1/ads/insights:
get:
x-resource-group: "ads"
operationId: queryAdInsights
tags: ["Ad Insights"]
x-platforms: ["meta", "google"]
summary: Flexible live insights query
description: |
Live, flexible insights query. The account's platform picks the contract:
**Meta (facebook/instagram)**: forwards caller-chosen `fields`, `breakdowns` and `filtering`
to any Meta insights node and returns Meta's rows verbatim. `objectId` (required) selects the
node; `level` sets row granularity. Semantic validation is Meta's: an unknown field or invalid
breakdown combination returns a 400 carrying Meta's message. For long ranges or agency-scale
accounts prefer the async variant (POST /v1/ads/insights/reports).
**Google Ads (googleads)**: raw GAQL passthrough. Send any read-only GAQL SELECT via `query`
(campaign/keyword/search-term/geo/demographic/asset/shopping resources, `change_event`, any
`segments.*`) and rows come back verbatim (camelCase, counters as strings). Results are paged
at a fixed 10,000 rows; follow `paging.nextPageToken` with `pageToken`. `customerId` is only
needed when the connection has several Google Ads accounts. Semantic validation is Google's:
an invalid query returns a 400 carrying Google's message (note: selecting `segments.date`
requires a finite date filter).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant); its platform selects the Meta or Google contract." }
- { name: objectId, in: query, schema: { type: string }, description: "Meta only (required there): insights node — act_<n>, campaign id, ad set id or ad id." }
- { name: query, in: query, schema: { type: string, maxLength: 10000 }, description: "Google only (required there): the GAQL SELECT statement to run." }
- { name: customerId, in: query, schema: { type: string }, description: "Google only: numeric customer id (no dashes) when the connection has several Google Ads accounts." }
- { name: pageToken, in: query, schema: { type: string }, description: "Google only: cursor from paging.nextPageToken of the previous page." }
- { name: level, in: query, schema: { type: string, enum: [ad, adset, campaign, account] }, description: Row granularity }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph insights fields (e.g. spend,impressions,frequency,website_purchase_roas). Omitted = Meta's default set." }
- { name: breakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph breakdowns (e.g. age,gender or publisher_platform)." }
- { name: actionBreakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph action breakdowns. Segments the actions[] arrays in each row." }
- { name: actionAttributionWindows, in: query, schema: { type: string }, description: "Comma-separated Meta attribution windows. Action values are returned keyed per window." }
- { name: actionReportTime, in: query, schema: { type: string }, description: "When actions are counted: impression, conversion or mixed." }
- { name: useUnifiedAttributionSetting, in: query, schema: { type: boolean }, description: "Use the ad sets' own attribution settings for action counting." }
- { name: filtering, in: query, schema: { type: string }, description: "JSON array of Meta filter objects: [{\"field\", \"operator\", \"value\"}]. Applied server-side by Meta." }
- { name: datePreset, in: query, schema: { type: string }, description: "Meta date_preset (e.g. last_7d, last_30d, this_month). Mutually exclusive with fromDate/toDate." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD); requires toDate." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD); requires fromDate." }
- { name: timeIncrement, in: query, schema: { type: string }, description: "Days per row (1-90), monthly, or all_days." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Insight rows (raw platform shape)
content:
application/json:
schema:
type: object
properties:
objectId: { type: string, description: "Meta responses only." }
customerId: { type: string, description: "Google responses only: the customer the query ran against." }
fieldMask: { type: [string, "null"], description: "Google responses only: the selected fields echoed by Google." }
data:
type: array
items: { type: object, description: "Raw platform row. Meta: keys are the requested Graph fields plus date_start/date_stop. Google: nested GAQL resources in camelCase, int64 counters as strings." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Meta cursor for the next page; null when exhausted." }
nextPageToken: { type: [string, "null"], description: "Google cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or the platform rejected the query (unknown field, invalid breakdown combo, malformed GAQL) — message carries the platform's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: "Platform rate limit reached. For Google this is the per-user operations budget or the shared quota; the message says which and when it resets." }
'501': { description: Only supported on Meta (facebook/instagram) and Google Ads }
/v1/ads/insights/reports:
post:
x-resource-group: "ads"
operationId: createAdInsightsReport
tags: ["Ad Insights"]
x-platforms: ["meta"]
summary: Submit an async insights report run
description: |
Submits an asynchronous Meta insights report. Same query surface as GET /v1/ads/insights, but
in the JSON body; Meta processes the report server-side, which is the right choice for long
ranges or large accounts where the sync query is slow or rate-limited. Returns a `reportRunId`
to poll via GET /v1/ads/insights/reports/{reportRunId}.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, objectId]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
objectId: { type: string, description: "Meta insights node: act_<n>, campaign id, ad set id or ad id." }
level: { type: string, enum: [ad, adset, campaign, account] }
fields: { type: string, description: "Comma-separated Graph insights fields." }
breakdowns: { type: string, description: "Comma-separated Graph breakdowns." }
actionBreakdowns: { type: string, description: "Comma-separated Graph action breakdowns (e.g. action_type,action_destination)." }
actionAttributionWindows:
type: array
items: { type: string }
description: "Meta attribution windows (e.g. [\"7d_click\", \"1d_view\"]). Action values are returned keyed per window."
actionReportTime: { type: string, description: "When actions are counted: impression, conversion or mixed." }
useUnifiedAttributionSetting: { type: boolean, description: "Use the ad sets' own attribution settings for action counting." }
filtering:
type: array
description: "Meta filter objects, applied server-side."
items:
type: object
required: [field, operator]
properties:
field: { type: string }
operator: { type: string }
value: {}
datePreset: { type: string, description: "Mutually exclusive with fromDate/toDate." }
fromDate: { type: string, format: date }
toDate: { type: string, format: date }
timeIncrement:
oneOf: [{ type: integer, minimum: 1, maximum: 90 }, { type: string, enum: [monthly, all_days] }]
responses:
'202':
description: Report run submitted
content:
application/json:
schema:
type: object
properties:
reportRunId: { type: string }
status: { type: string, example: Job Started }
'400': { description: "Invalid input, or Meta rejected the report parameters" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/insights/reports/{reportRunId}:
get:
x-resource-group: "ads"
operationId: getAdInsightsReport
tags: ["Ad Insights"]
x-platforms: ["meta"]
summary: Poll an async insights report run
description: |
Status and results for a report run created via POST /v1/ads/insights/reports. While the job
runs, returns `status` and `percentCompletion`. Once `status` is "Job Completed" the response
also carries a `data` page, cursor-paginated via `limit` / `after`.
security:
- bearerAuth: []
parameters:
- { name: reportRunId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id used to resolve the Meta token (must be the same connection that created the run)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 } }
- { name: after, in: query, schema: { type: string } }
responses:
'200':
description: Report run status (plus results when completed)
content:
application/json:
schema:
type: object
properties:
reportRunId: { type: string }
status: { type: string, description: "Meta async_status: Job Not Started, Job Started, Job Running, Job Completed, Job Failed, Job Skipped." }
percentCompletion: { type: integer }
dateStart: { type: string }
dateStop: { type: string }
data:
type: array
items: { type: object }
description: "Present only when status is Job Completed."
paging:
type: object
properties:
after: { type: [string, "null"] }
'400': { description: "Invalid input, or the report run is not readable with this account's token" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{adId}/analytics:
get:
x-resource-group: "ads"
operationId: getAdAnalytics
tags: ["Ad Insights"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Get ad analytics
description: |
Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline
over the requested date range, and optional demographic breakdowns (Meta and TikTok only).
If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of date range (YYYY-MM-DD). Defaults to 90 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
- name: breakdowns
in: query
schema: { type: string }
description: |
Comma-separated breakdown dimensions.
**Meta**: age, gender, country, publisher_platform, device_platform, region.
**TikTok**: gender, age, country_code, platform, ac, language.
**LinkedIn** (firmographics): job_title, job_function, seniority, industry,
company, company_size, country, region. Rows carry the raw pivot `value`
plus a resolved `name`. LinkedIn serves these aggregated over the whole
range, delays the data 12-24h, and omits segments with fewer than 3 events.
responses:
'200':
description: Ad analytics
content:
application/json:
schema: { $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 — no need to read back headline/body/CTA. `creative` (headline, body, callToAction,
linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post
/ dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for
`creative`).
- Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies).
- LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM).
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
urlTags:
type: array
description: "Meta only. Click-URL params appended to a freshly-rebuilt creative. Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded."
items:
type: object
required: [key, value]
properties:
key: { type: string }
value: { type: string }
creative:
type: object
description: "Meta only. OPTIONAL — omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips."
required: [headline, body, callToAction, linkUrl, imageUrl]
properties:
headline: { type: string }
body: { type: string }
callToAction: { type: string }
linkUrl: { type: string, format: uri }
imageUrl: { type: string, format: uri }
videoUrl: { type: string, format: uri }
trackingUrlTemplate: { type: string, description: "Google only. Full tracking template (must contain {lpurl})." }
finalUrlSuffix: { type: string, description: "Google only. Parse-only key=value params." }
dynamicValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...)." }
customValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> static value." }
responses:
'200': { description: "Updated" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Ad not found" }
'405': { description: "Platform has no click-URL tracking surface" }
'422': { description: "Meta creative cannot be rebuilt (e.g. placement-customized/asset-feed/dark creative)" }
'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"]
summary: List comments on an ad
description: |
Returns comments on an ad's underlying creative post. Useful for moderating or analyzing
engagement on dark posts (ad creatives that never went live organically), which the
regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are
not in Zernio's post database.
An ad that runs on both Facebook feed and Instagram feed has two separate underlying
posts with separate comment threads (the creative's effective_object_story_id and
effective_instagram_media_id). Use the `placement` query param to pick one; with no
param the Instagram side is returned when it exists, otherwise Facebook. The
identifiers are read from the ad record (persisted during sync) with a Marketing-API
fallback for ads that predate the field.
For Instagram-placed comments, the Instagram account that runs the ad must be connected
to Zernio — those comments are read through that account's token. If no connected
Instagram account on the profile can read the ad's media, the call returns
ads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook).
Meta-only for now. Other ad platforms (TikTok, LinkedIn, Pinterest, Google, X)
are not wired to this endpoint and return feature_not_available.
Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}.
The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
Zernio internal `_id` (24-char hex), Meta's numeric `platformAdId` (the value shipped in
`comment.received` webhooks as `comment.ad.id`), or the creative's
`effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a
translation step.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Internal Zernio ad ID (ObjectId)." }
- { name: placement, in: query, schema: { type: string, enum: [facebook, instagram] }, description: "Which side of the ad to return comments for. Omit to default to the Instagram side when present, else Facebook. Returns ad_not_commentable if the ad has no such placement." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
- { name: cursor, in: query, schema: { type: string }, description: "Pagination cursor from a previous response." }
responses:
'200':
description: Comments on the ad
content:
application/json:
schema:
type: object
required: [status, comments, pagination, meta]
properties:
status: { type: string, enum: [success] }
comments:
type: array
items:
type: object
description: Normalized comment. Same shape as /v1/inbox/comments/{postId} responses.
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: string }
meta:
type: object
required: [platform, placement, adId, platformAdId, effectiveStoryId, accountId, lastUpdated]
properties:
platform: { type: string, enum: [facebook, instagram], description: "Which side these comments are on (same as `placement`)." }
placement: { type: string, enum: [facebook, instagram], description: "The placement these comments are for — useful when you didn't pass ?placement= and want to know which one you got." }
adId: { type: string, description: "Internal Zernio ad ID." }
platformAdId: { type: string, description: "Meta ad ID." }
effectiveStoryId:
type: string
description: "Underlying post ID the comments belong to. effective_object_story_id for the Facebook side, effective_instagram_media_id for the Instagram side."
facebookAccountId:
type: [string, "null"]
description: "Facebook-only. The connected Facebook Page SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete. Null when no connected Page was used (then moderation isn't possible)."
instagramUserId:
type: string
description: "Instagram-only. The Instagram-scoped business ID that owns the boosted media (creative.instagram_user_id)."
instagramPermalink:
type: string
description: "Instagram-only. Public permalink of the boosted IG post (creative.instagram_permalink_url)."
instagramAccountId:
type: string
description: "Instagram-only. The connected Instagram SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete."
accountId: { type: string, description: "Social account ID (ads SocialAccount)." }
lastUpdated: { type: string, format: date-time }
'400':
description: |
Invalid ad ID format, or the ad's creative format does not expose a commentable
underlying post (code ad_not_commentable).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required (legacy plans need the Ads add-on; included by default on usage-based plans), or ad platform is not Meta (code feature_not_available).
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: |
Ads account token unavailable, or (for Instagram-placed ads) no connected
Instagram account on the profile can read the ad's media (code ads_connection_required).
/v1/ads/business-centers:
get:
x-resource-group: "ads"
operationId: listAdsBusinessCenters
tags: ["Ad Accounts"]
x-platforms: ["tiktok"]
summary: List TikTok Business Centers
description: |
Returns the TikTok Business Centers (BCs) the connected `tiktokads` account can read.
Each BC reports its advertiser count so callers can build agency-style pickers
without re-walking `/v1/ads/accounts` per BC.
TikTok-only. Solo advertisers (non-agency tokens) return an empty array.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount }
responses:
'200':
description: Business centers
content:
application/json:
schema:
type: object
properties:
businessCenters:
type: array
items: { $ref: '#/components/schemas/BusinessCenter' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: TikTok account not found }
'422': { description: TikTok Ads not connected }
/v1/ads/activity:
get:
x-resource-group: "ads"
operationId: getAdsActivityLog
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Ad account change / audit log
description: |-
Account-level audit log from Meta's `/act_X/activities`: who changed what and when
(creates, edits, status flips, budget changes...) with Meta's translated event names and
the structured before/after in `extra_data`. Rows are returned verbatim. Meta has no
server-side per-object filter on this edge, so `objectId` filters the returned page
client-side (combine with paging to walk history for one campaign/ad set/ad).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: since, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD)." }
- { name: until, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD)." }
- { name: objectId, in: query, schema: { type: string }, description: "Client-side filter to one Meta object id (campaign, ad set or ad)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Activity rows (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta activity row: event_type, translated_event_type, event_time, actor_name, object_id, object_name, object_type, application_name, extra_data (JSON string with before/after)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/rf-predictions:
post:
x-resource-group: "ads"
operationId: createRfPrediction
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: Create a Reach & Frequency prediction
description: |-
Creates an R&F prediction — a QUOTE, nothing is bought and no ad entities are created.
Provide a date range plus exactly one of `budgetAmount` (Meta predicts reach) or `reach`
(Meta predicts the budget). The response carries the estimate and its allowed bounds
(min/max budget and reach). Predictions expire on their own; to buy, reserve one via
POST /v1/ads/rf-predictions/{predictionId}/reserve and pass the RESERVED id to
POST /v1/ads/create with `buyingType: "RESERVED"`.
Reservation campaigns reject automatic placements. Top-level `placements` wins; when it is
omitted, `targeting.placements` is used; when neither is set, placements default to
Facebook feed (+ Instagram stream when a linked IG professional account resolves).
Instagram placements require that IG account.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, startDate, endDate]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
budgetAmount: { type: number, description: "Whole currency units. Exactly one of budgetAmount / reach." }
reach: { type: integer, description: "Target unique reach. Exactly one of budgetAmount / reach." }
startDate: { type: string, format: date-time, description: "Campaign window start (must be in the future)." }
endDate: { type: string, format: date-time }
frequencyCap: { type: integer, minimum: 1, maximum: 90, description: "Max impressions per person over the window." }
targeting: { type: object, description: "Canonical camelCase TargetingSpec (same shape as /v1/ads/create's `targeting`). Defaults to countries: [US]." }
placements: { type: object, description: "Meta placements object (same shape as /v1/ads/create's `placements`)." }
responses:
'201':
description: Prediction created (usually ready within seconds)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
currency: { type: string }
prediction: { $ref: '#/components/schemas/RfPrediction' }
'400': { description: "Invalid input, or Meta rejected the prediction — message carries Meta's error" }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "No Facebook Page resolved for the account" }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/rf-predictions/{predictionId}:
get:
x-resource-group: "ads"
operationId: getRfPrediction
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: Read a Reach & Frequency prediction
security:
- bearerAuth: []
parameters:
- { name: predictionId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Prediction status and estimates
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
currency: { type: string }
prediction: { $ref: '#/components/schemas/RfPrediction' }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: cancelRfReservation
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: Cancel a Reach & Frequency reservation
description: Releases a RESERVATION's locked price and inventory. Unreserved predictions expire on their own.
security:
- bearerAuth: []
parameters:
- { name: predictionId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Reservation cancelled
'400': { description: "Invalid input, or Meta rejected the cancel" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/rf-predictions/{predictionId}/reserve:
post:
x-resource-group: "ads"
operationId: reserveRfPrediction
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: Reserve a Reach & Frequency prediction
description: |-
Locks the quoted price + inventory until the returned `expiresAt` and mints a NEW
prediction id — pass that RESERVED id (not the original) as `rfPredictionId` on
POST /v1/ads/create. Release an unused reservation via DELETE.
security:
- bearerAuth: []
parameters:
- { name: predictionId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId]
properties:
accountId: { type: string }
adAccountId: { type: string }
responses:
'201':
description: Reserved; `prediction.predictionId` is the new RESERVED id
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
prediction: { $ref: '#/components/schemas/RfPrediction' }
'400': { description: "Invalid input, or Meta rejected the reserve" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/studies:
get:
x-resource-group: "ads"
operationId: listAdStudies
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: A/B tests and lift studies
description: |-
Lists the ad account's A/B tests and lift studies (Meta's `/act_X/ad_studies`), rows
returned verbatim. The default projection covers id, name, type, timing and cells with
split percentages; `fields` is a raw-passthrough override.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Ad studies (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad study row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/businesses:
get:
x-resource-group: "ads"
operationId: listMetaBusinesses
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Businesses list
description: |-
Business Manager portfolios the connected Meta user belongs to (Meta's `/me/businesses`),
rows returned verbatim (id, name, verification_status, created_time). Token-scoped, so no
`adAccountId` is needed. For TikTok Business Centers use
`GET /v1/ads/business-centers`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Businesses (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { type: object, description: "Raw Meta business row (id, name, verification_status, created_time)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/labels:
get:
x-resource-group: "ads"
operationId: listAdLabels
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Ad labels
description: |-
Lists the ad account's organizational labels (Meta's `/act_X/adlabels`), rows returned
verbatim (id, name, created/updated time).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Ad labels (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad label row (id, name, created_time, updated_time)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/high-demand-periods:
get:
x-resource-group: "ads"
operationId: listHighDemandPeriods
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: High demand periods / budget schedules
description: |-
Scheduled budget increases (Meta's budget-scheduling API). The Graph edge lives on the
campaign and ad-set nodes only, so exactly one of `campaignId` / `adSetId` (platform
ids) is required. Rows returned verbatim (budget_value, budget_value_type, time window,
recurrence).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: campaignId, in: query, schema: { type: string }, description: "Platform campaign id. Exactly one of campaignId / adSetId." }
- { name: adSetId, in: query, schema: { type: string }, description: "Platform ad set id. Exactly one of campaignId / adSetId." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Budget schedules (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
objectId: { type: string, description: "The campaign / ad set id the schedules belong to." }
data:
type: array
items: { type: object, description: "Raw Meta high-demand-period row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
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:
'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 }, description: "Comma-separated Graph field override (supports nested {} projections)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Creatives (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta creative row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
post:
x-resource-group: "ads"
operationId: createAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Create a standalone creative
description: |-
Creates a creative in the library WITHOUT an ad, reusable on the create endpoints via
`existingCreativeId`. Provide exactly one of `imageUrl` (uploaded server-side),
`imageHash` (from POST /v1/ads/images or the library list), or `carouselCards` (2-10
hand-built cards). The Page (and linked Instagram account, when present) is resolved
from `accountId` as the story actor.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, headline, body, linkUrl]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token and Page." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
headline: { type: string, maxLength: 255 }
body: { type: string, description: Primary text }
description: { type: string, maxLength: 255, description: "Link description below the headline; omitted = Meta scrapes the destination's OG description." }
callToAction: { type: string, default: LEARN_MORE, description: "CTA type (same whitelist as POST /v1/ads/create)." }
linkUrl: { type: string, format: uri }
imageUrl: { type: string, format: uri, description: "Publicly reachable image; uploaded to the account's library server-side." }
imageHash: { type: string, description: "Existing library image hash (POST /v1/ads/images or GET /v1/ads/images)." }
carouselCards:
type: array
minItems: 2
maxItems: 10
items:
type: object
required: [imageUrl, linkUrl]
properties:
imageUrl: { type: string, format: uri }
linkUrl: { type: string, format: uri }
headline: { type: string, maxLength: 255 }
description: { type: string, maxLength: 255 }
callToAction: { type: string }
urlTags: { type: string, description: "Appended to every outbound URL (e.g. utm_source=fb)." }
creativeFeatures:
type: object
additionalProperties: { type: string, enum: [OPT_IN, OPT_OUT] }
description: 'Advantage+ creative enhancements: partial map of Meta creative feature keys (snake_case) to enroll status, forwarded as degrees_of_freedom_spec.creative_features_spec. Unspecified features default to OPT_OUT.'
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 — Meta rejects it there."
responses:
'201':
description: Creative created
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
creativeId: { type: string, description: "Platform creative id, reusable via existingCreativeId." }
'400': { description: "Invalid input, or Meta rejected the create" }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: No Facebook Page found to act as the story actor }
'501': { description: Only supported on Meta (facebook/instagram) }
'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 }, description: "Comma-separated Graph field override (supports nested {} projections)." }
responses:
'200':
description: Creative details
content:
application/json:
schema:
type: object
properties:
creative: { type: object, description: Raw Meta creative node }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
put:
x-resource-group: "ads"
operationId: updateAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Rename a creative
description: |-
Renames a creative. Creatives are immutable on Meta beyond `name` — for content changes
create a new creative (POST /v1/ads/creatives) and swap it onto the ad
(PUT /v1/ads/{adId} with `creative`).
security:
- bearerAuth: []
parameters:
- { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
name: { type: string, maxLength: 255 }
responses:
'200':
description: Creative renamed
content:
application/json:
schema:
type: object
properties:
creativeId: { type: string }
name: { type: string }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the update" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: deleteAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Delete a creative
description: |-
Deletes a creative from the library. Meta only allows deleting creatives not referenced
by any ad — otherwise its 400 surfaces verbatim.
security:
- bearerAuth: []
parameters:
- { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'200':
description: Creative deleted
content:
application/json:
schema:
type: object
properties:
creativeId: { type: string }
message: { type: string }
'400': { description: "Invalid input, the creative is in use, or Meta rejected the delete" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/value-rule-sets:
get:
x-resource-group: "ads"
operationId: listValueRuleSets
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: List value rule sets
description: |-
Lists the ad account's value rule sets (Meta's `/act_X/value_rule_set`). A value rule
set adjusts the auction bid up or down for audience segments you value differently;
attach one to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
`PUT /v1/ads/ad-sets/{adSetId}`.
Rows are returned in the same camelCase shape the `PUT` body takes, ids included, so a
set round-trips 1:1: **the update is a full replace, not a patch**, so you GET, mutate
and send the whole thing back.
Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule.
**Rule order is semantic.** Rules are evaluated in array order and only the FIRST
matching rule adjusts the bid for an overlapping audience. The order you send is the
order that is stored and returned.
Eligibility: value rule sets apply only to ad sets on the `LOWEST_COST_WITHOUT_CAP`
(auto-bid) or `COST_CAP` bid strategies. Meta rejects the rest server-side.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page. Meta does not document paging on this edge; `after` comes back null when it omits cursors." }
responses:
'200':
description: Value rule sets
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { $ref: '#/components/schemas/ValueRuleSet' }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted or when Meta omits paging." }
'400': { description: "Invalid input, or Meta rejected the query. Meta answers a bad rule-set id with GraphMethodException code 100 / subcode 33, which is indistinguishable between not-found, no-permission, and account-not-enabled." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
post:
x-resource-group: "ads"
operationId: createValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Create a value rule set
description: |-
Creates a value rule set on the ad account (Meta's `POST /act_X/value_rule_set`).
Attach the returned id to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
`PUT /v1/ads/ad-sets/{adSetId}`.
**Rule order is semantic**: rules are evaluated in array order and only the first
matching rule adjusts the bid for an overlapping audience.
`adjustValue` is an unsigned magnitude in percent; the direction lives in `adjustSign`.
`INCREASE` accepts 1-1000, `DECREASE` accepts 1-90. There is no signed field and 0 is
out of range.
`criteriaValueTypes` is positionally paired with `criteriaValues` (same length, same
order). Every type is the literal `"NONE"` except on `LOCATION`, which uses
`LOCATION_COUNTRY` / `LOCATION_REGION` / `LOCATION_CITY` / `LOCATION_COMSCORE_MARKET`
and may mix them within one criterion. Location values are Targeting-Search keys: a
two-letter country code for `LOCATION_COUNTRY`, a numeric key for the rest.
`LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on 2026-06-22 and rules using
DMAs are no longer active, so this API rejects it.
`AUDIENCE_LABEL` values (e.g. `HIGH_VALUE`) are applied to a Custom Audience in Ads
Manager. There is no API to provision them, so label strings are passed through
unvalidated and a typo produces a rule that never fires.
Ads Manager turns a rule set read-only (this API stays editable) when a rule uses more
than 2 criteria, a custom age range, or the placements `FB_MARKETPLACE`, `FB_SEARCH`,
`FB_VIDEO` or `IG_EXPLORE`.
Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule. The
per-account cap is enforced by Meta, not here.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, rules]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
name: { type: string, minLength: 1, maxLength: 255 }
rules:
type: array
minItems: 1
maxItems: 10
description: "Evaluated in order; the first matching rule wins."
items: { $ref: '#/components/schemas/ValueRule' }
responses:
'201':
description: Value rule set created
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
valueRuleSetId: { type: [string, "null"], description: "The new rule set id. Meta does not document the create response body, so this is null on the (unobserved) case where it omits the id." }
'400': { description: "Invalid input, or Meta rejected the create (per-account rule-set cap, ineligible criteria, or an account that is not enabled for value rules)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/value-rule-sets/{valueRuleSetId}:
get:
x-resource-group: "ads"
operationId: getValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Read a value rule set
description: |-
Reads one value rule set including every nested rule id and criterion id. This is step
one of any edit: `PUT` is a full replace, so you need the ids before you can keep the
objects you are not changing.
Meta's own read returns `GENDER` values lowercase (`"male"`) while writes require
`"MALE"`. Values are passed through untouched, so never case-compare a stored rule
against a fetched one.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'200':
description: Value rule set
content:
application/json:
schema:
type: object
properties:
valueRuleSet: { $ref: '#/components/schemas/ValueRuleSet' }
'400': { description: "Invalid input, or Meta rejected the read. A bad id comes back as GraphMethodException code 100 / subcode 33, which cannot be told apart from a permission problem." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
put:
x-resource-group: "ads"
operationId: updateValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Replace a value rule set
description: |-
**THIS IS A FULL REPLACE, NOT A PATCH.** Meta's update is declarative: the body you
send becomes the rule set.
- `GET /v1/ads/value-rule-sets/{valueRuleSetId}` FIRST.
- Keep a rule or criterion by echoing its `id`.
- Create one by including the object WITHOUT an `id`.
- Delete one by OMITTING it from the array. There is no warning and no undo.
`name` and `rules` are both required for exactly this reason: a partial body would
silently destroy every rule left out.
**Rule order is semantic**: the array order you send is the evaluation order, and only
the first matching rule adjusts the bid for an overlapping audience.
Existing rule sets created elsewhere may contain `LOCATION_DMA` criteria. Those went
inert on 2026-06-22 and are rejected here; migrate them to `LOCATION_COMSCORE_MARKET`.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, rules]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
name: { type: string, minLength: 1, maxLength: 255, description: "Required: the update replaces the whole set." }
rules:
type: array
minItems: 1
maxItems: 10
description: "The COMPLETE rule list. Omitting a rule deletes it on Meta."
items: { $ref: '#/components/schemas/ValueRule' }
responses:
'200':
description: Value rule set replaced
content:
application/json:
schema:
type: object
properties:
valueRuleSetId: { type: string }
name: { type: string }
rules:
type: array
items: { $ref: '#/components/schemas/ValueRule' }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the update" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: deleteValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Delete a value rule set
description: |-
Deletes the rule set (Meta's `POST /{value-rule-set-id}/delete_rule_set`, a custom
action edge rather than an HTTP DELETE on its side). Ad sets pointing at it are not
modified here; detach them first with `valueRulesApplied: false` on
`PUT /v1/ads/ad-sets/{adSetId}`.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'200':
description: Value rule set deleted
content:
application/json:
schema:
type: object
properties:
valueRuleSetId: { type: string }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the delete. A bad id comes back as GraphMethodException code 100 / subcode 33, which reads like a permission error rather than a 404." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/accounts/finance:
get:
x-resource-group: "ads"
operationId: getAdAccountFinance
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Ad account finances
description: |-
Finances of one Meta ad account: prepaid `balance`, lifetime `amountSpent`, account
`spendCap` (null = no cap) and the `fundingSource`. Money values are converted from
Meta's minor units to whole units of `currency`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
responses:
'200':
description: Account finances
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
currency: { type: string, description: "ISO 4217 code all money values are expressed in." }
balance: { type: number, description: "Outstanding/prepaid balance in whole currency units." }
amountSpent: { type: number, description: "Lifetime amount spent in whole currency units." }
spendCap: { type: [number, "null"], description: "Account spend cap; null when none is set." }
fundingSource:
type: [object, "null"]
properties:
displayString: { type: string, description: "Human-readable payment method, e.g. 'Mastercard *4985'." }
type: { type: integer, description: "Meta funding source type code." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/accounts:
get:
x-resource-group: "ads"
operationId: listAdAccounts
tags: ["Ad Accounts"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List ad accounts
description: |
Returns the platform ad accounts available for the given social account (e.g. Meta ad
accounts, TikTok advertiser IDs, Google Ads customer IDs).
For TikTok agencies: enumerates every advertiser under every Business Center the token
can read (paginated server-side), then chunks the lookup against TikTok's
`/advertiser/info/` endpoint (which has a per-call cap of ≤100 IDs). Solo advertisers
without a BC fall back to the OAuth-time `advertiser_ids` list. Cached for 1h on the
SocialAccount; lazy-refreshed on first call after expiry.
For Google Ads: responds `429` when Google's API quota is temporarily exhausted
(instead of an empty list). Retry after a delay.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
- { name: adAccountId, in: query, required: false, schema: { type: string }, description: "Filter response to a single platform ad account ID (e.g. `act_123` for Meta, advertiser_id for TikTok). Returns at most one item." }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000 }, description: "Clamp the returned `accounts[]` length. Useful for typeahead pickers on agency tokens with hundreds of advertisers." }
responses:
'200':
description: Ad accounts
content:
application/json:
schema:
type: object
properties:
accounts:
type: array
items:
type: object
properties:
id: { type: string, description: "Platform ad account ID (e.g. act_123)" }
name: { type: string }
currency: { type: string }
status: { type: string, description: "LinkedIn only. LinkedIn's own ad account status. In practice always `ACTIVE`, because the LinkedIn query filters to active accounts. Meta, Google, TikTok and Pinterest report `accountStatus` instead; X reports `approvalStatus`." }
accountStatus:
description: |
The platform's own account status, forwarded unchanged. No JSON type is
declared because the type differs per platform: Meta sends an integer,
Google, TikTok and Pinterest send a string. Absent on LinkedIn (reports
`status`) and on X (reports `approvalStatus`).
If all you need is whether the account can run ads right now, read
`selectable` and skip this field. Read this one when you need to tell
the states apart, because they call for different responses:
- `1` ACTIVE. Running normally.
- `2` DISABLED. Disabled by Meta. Read `disableReason` to tell a policy
action apart from a billing one; they need very different follow-ups.
- `3` UNSETTLED. There is an unpaid balance, but the account still runs
ads. Not a ban.
- `7` PENDING_RISK_REVIEW. Meta is reviewing the account. Wait for the
outcome.
- `8` PENDING_SETTLEMENT. Meta blocks new ads until an outstanding
balance clears. Settle it and the account runs again.
- `9` IN_GRACE_PERIOD. Still running, on a deadline.
- `100` PENDING_CLOSURE. Scheduled to close.
- `101` CLOSED. Terminal.
approvalStatus: { type: string, description: "X only. X's own ad account approval status. Observed values are `ACCEPTED`, `PENDING` and `REJECTED`, but X does not publish the full vocabulary, so treat an unrecognised value as not usable. Other platforms report `accountStatus` or `status` instead." }
disableReason: { type: integer, description: "Meta only. Meta's `disable_reason` code, forwarded unchanged. Present when `accountStatus` is `2` (DISABLED) and Meta gives a reason, which is what separates a policy action from a payment problem. Meta does not publish a stable list of values for this field, so none are enumerated here: resolve the code against Meta's own ad account reference. Absent when Meta reports no reason, or when the connected token cannot read the field." }
timezoneName: { type: string, description: "IANA timezone of the ad account (Meta only). Drives daily-budget reset and Insights day boundaries." }
timezoneOffsetHoursUtc: { type: number, description: "Signed UTC offset in hours, reflecting current DST (Meta only)." }
minimumDailyBudget: { type: number, description: "Meta only. Minimum daily budget for the account, in the account currency's major units. This is the impressions-billed minimum; other billing events have higher minimums. Absent when the connected token cannot read it." }
selectable: { type: boolean, description: "Meta and X only. Whether the account can create/run ads now. Absent (treat as true) on other platforms." }
unusableReason: { type: [string, "null"], description: "Meta and X only. Human-readable reason when selectable is false; null when selectable." }
'401': { $ref: '#/components/responses/Unauthorized' }
'422':
description: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account
'429': { $ref: '#/components/responses/RateLimited' }
patch:
x-resource-group: "ads"
operationId: updateAdAccount
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Update ad account settings
description: |
Sets the default DSA beneficiary and payor on a Meta ad account (EU DSA, Article 26).
Set them once and every EU-targeted call to `/v1/ads/create`, `/v1/ads/boost` and
`/v1/ads/ctwa` on that ad account can omit `dsaBeneficiary`/`dsaPayor`: Meta applies
the defaults automatically.
The values are written to the ad account on Meta, the same setting Ads Manager edits.
Nothing is stored in Zernio, and defaults already set in Ads Manager work identically.
Zernio never guesses these values for you. Beneficiary and payor are legal disclosures
shown to EU users, so you must provide the entity names explicitly. Use
`GET /v1/ads/dsa-recommendations` to offer suggestions in your UI.
If `defaultDsaPayor` is omitted, the beneficiary is also set as the payor, which
covers the common case where the same entity benefits from and pays for the ads.
Read the current values back with `GET /v1/ads/dsa-defaults`.
Currently supported for Meta accounts only; other platforms return 400.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, defaultDsaBeneficiary]
properties:
accountId: { type: string, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
adAccountId: { type: string, description: "Meta ad account ID (act_...)" }
defaultDsaBeneficiary: { type: string, maxLength: 100, description: "Legal entity benefiting from ads on this ad account" }
defaultDsaPayor: { type: string, maxLength: 100, description: "Legal entity paying for ads on this ad account. Defaults to defaultDsaBeneficiary when omitted." }
responses:
'200':
description: DSA defaults updated (re-read from Meta after the write)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
dsaDefaults:
type: object
properties:
beneficiary: { type: string }
payor: { type: string }
'400':
description: Unsupported platform (non-Meta account) or invalid adAccountId
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Social account not found
/v1/ads/dsa-defaults:
get:
x-resource-group: "ads"
operationId: getDsaDefaults
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Get ad account DSA defaults
description: |
Returns the default DSA beneficiary and payor currently set on a Meta ad account,
whether they were set via `PATCH /v1/ads/accounts` or in Meta Ads Manager. Fields
are omitted when no default is configured. Meta accounts only.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
responses:
'200':
description: Current DSA defaults (empty object when none are set)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
dsaDefaults:
type: object
properties:
beneficiary: { type: string }
payor: { type: string }
'400':
description: Non-Meta adAccountId
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Social account not found
/v1/ads/dsa-recommendations:
get:
x-resource-group: "ads"
operationId: getDsaRecommendations
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: List DSA beneficiary/payor suggestions
description: |
Returns Meta's suggested beneficiary/payor names for an ad account, derived by Meta
from the account's recent activity. Useful for prefilling `dsaBeneficiary`/`dsaPayor`
inputs, or the defaults sent to `PATCH /v1/ads/accounts`, in your own UI.
Meta returns a single flat list. Entries are not labeled as beneficiary or payor,
and since these are legal disclosures Zernio never applies them automatically: let
your user pick the right entity. The list may be empty for accounts with little
activity. Meta accounts only.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
responses:
'200':
description: Suggested DSA strings (may be empty when Meta has no recommendations)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
recommendations:
type: array
items: { type: string }
'400':
description: Non-Meta adAccountId
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Social account not found
/v1/ads/boost:
post:
x-resource-group: "ads"
operationId: boostPost
tags: ["Ad Campaigns"]
x-platforms: ["meta"]
summary: Boost post as ad
description: |
Creates a paid ad from an existing published post, keeping the post's
engagement. By default it provisions the whole hierarchy (campaign, ad
set, ad).
**Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING
ad set instead, so that ad set keeps its learning phase. It then owns
`budget`, `schedule` and `targeting`, and sending any of those alongside
`adSetId` is a 400 rather than a silent drop. `budget` is required only
without `adSetId`.
`instagramAccountId`, `destinationType` and `adSetId` are Meta-only and
return 400 on other platforms.
**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:
postId: { type: string, description: Zernio post ID (provide this or platformPostId) }
platformPostId: { type: string, description: Platform post ID (alternative to postId) }
accountId: { type: string, description: Social account ID }
adAccountId: { type: string, description: Platform ad account ID }
name: { type: string, maxLength: 255 }
goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views." }
adSetId: { type: string, description: "Meta only. Attach the boosted post to this existing ad set instead of creating a campaign. The ad set then owns budget, schedule and targeting; sending those too is a 400." }
budget:
type: object
description: "Required unless adSetId is set."
required: [amount, type]
properties:
amount: { type: number, description: "Minimum varies: TikTok=$20, Pinterest=$5, others=$1" }
type: { type: string, enum: [daily, lifetime] }
instagramAccountId: { type: string, description: "Meta only. Instagram identity the ad runs AS (creative.instagram_user_id), overriding the account linked to the Page. Live-verified against a Page-post creative." }
destinationType: { type: string, enum: [INSTAGRAM_PROFILE, WEBSITE, ON_AD, MESSENGER, WHATSAPP], description: "Meta only. Ad-set destination_type — where the click LANDS, as opposed to instagramAccountId which is who the ad runs as. Lead ads force ON_AD and ignore this." }
currency: { type: string, 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 — 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 }
linkUrl:
type: string
format: uri
description: |
Destination URL for the CTA button. Send it together with `callToAction`.
**Meta**: adds a top-level `call_to_action` to the post-reference creative.
This is what gives a `traffic` boost a clickable destination without
replacing the creative and losing the post's social proof. Ignored when
`leadGenFormId` is set, which supplies its own destination. Live-verified
against a Page-post creative.
**TikTok**: maps to `landing_page_url` on the Spark Ad creative
(`AdcreateCreatives.landing_page_url`); Spark Ads have no clickable
destination without it.
Ignored on LinkedIn / Pinterest / X / Google, which infer the destination
from the boosted post.
callToAction:
type: string
description: |
CTA button label. Send it together with `linkUrl` — a CTA without a
destination produces a button that goes nowhere, so sending one alone is a 400.
**Meta**: the CTA enum of POST /v1/ads/create plus
`VIEW_INSTAGRAM_PROFILE`, which is accepted on boost only. For that
value `linkUrl` is typically the Instagram profile URL.
**TikTok**: pass-through to `call_to_action` on the Spark Ad creative; the
platform validates the value. See TikTok's "Enumeration - Call-to-Action".
sparkAuthCode:
type: string
description: |
TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator
Spark Ads — the advertiser can boost a video owned by a DIFFERENT TikTok
account. Without this, boosts are limited to videos owned by the same
account running the ads (same-BC creators only). The creator generates the
code in their TikTok app's Promote settings and shares it with the
advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.
dsaBeneficiary:
type: string
maxLength: 100
description: |
Legal entity that benefits from the ad. Required when targeting EU users
(EU DSA, Article 26). Optional if the ad account has a default beneficiary:
set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
fills it in whenever the field is omitted.
dsaPayor:
type: string
maxLength: 100
description: |
Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
(for example, an agency paying for a client's ads). Same rules as
`dsaBeneficiary`: required for EU targeting unless the ad account has
a default payor.
optimizationGoal:
type: string
description: |
Meta only. Explicit ad-set `optimization_goal` override. When omitted,
defaults to the value derived from `goal`. The value must be compatible
with the objective Meta derives from `goal`, not with the objective used
by `POST /v1/ads/create` for the same `goal` name: boost maps `goal:
"engagement"` to objective `OUTCOME_AWARENESS`, which accepts
`REACH`, `IMPRESSIONS`, `AD_RECALL_LIFT`, or THRUPLAY-class values, and
rejects `POST_ENGAGEMENT` (that value is only valid under
`OUTCOME_ENGAGEMENT`, which create uses for the same goal name).
responses:
'201':
description: Ad created
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
'400':
description: Missing required fields or invalid values
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'409':
description: |
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/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: >-
Creates a paid ad with custom creative across Meta, Google Ads,
Pinterest, TikTok, X/Twitter, LinkedIn, and OpenAI Ads (ChatGPT Ads).
Supports three mutually-exclusive request shapes selected by the body,
a legacy single-creative shape (all platforms, default), a Meta-only
multi-creative shape via the creatives array (one ad set with N ads
sharing budget and targeting), and an attach shape via adSetId that
adds one new ad to an existing ad set, inheriting its budget,
targeting, and schedule (Meta, TikTok, and LinkedIn; on LinkedIn
adSetId is the existing Campaign id, and the budget, schedule,
targeting and bidding fields must be omitted). Per-platform
required fields,
budget minimums, and video-ad rules are documented on each property
below. LinkedIn creates a Single Image or Single Video Ad backed by a
Direct Sponsored Content "dark post" authored by a Company Page (see
`organizationId`); supported goals are engagement, traffic, awareness,
and video_views (video ads use the `video` field; video_views requires
a video), and traffic ads require `linkUrl`.
**Idempotency:** this endpoint is not idempotent at the platform level (a
blind retry creates a second campaign/ad set/ad). Send an `Idempotency-Key`
header to make retries safe: the first request with a given key creates the
ad and we store the response; a retry with the same key replays that exact
response (with `Idempotent-Replayed: true`) instead of creating duplicates.
Reusing a key with a different body returns 422; a key whose first request
is still in flight returns 409 (retry after a short backoff). Keys are
scoped to your credential and expire after 24h.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name]
properties:
accountId: { type: string }
adAccountId: { type: string }
name: { type: string, maxLength: 255 }
campaignName: { type: string, maxLength: 255, description: "Meta only. Exact campaign name. Overrides the default `<name> - Campaign`." }
adSetName: { type: string, maxLength: 255, description: "Meta only. Exact ad set name. Overrides the default `<name> - Ad Set`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry.)" }
adName: { type: string, maxLength: 255, description: "Meta only. Exact ad name (the single-creative ad object's name). Overrides the default, which is `name`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry instead.)" }
tracking:
type: object
description: "Meta only. Attaches pixel measurement to the ad regardless of the optimization goal (the \"Website events\" tracking row in Ads Manager). `pixelId` becomes the ad's `tracking_specs` (offsite_conversion + fb_pixel); `urlTags` becomes the ad's `url_tags` (click-tracking query params). Applied on the legacy single-creative shape, every ad of the multi-creative shape, and the attach shape. NOTE: tracking lives on the AD object and is not inherited from the ad set, so pass it on EVERY attach call that should carry the pixel."
properties:
pixelId: { type: string, description: "Meta Pixel ID to attach for offsite-conversion measurement." }
urlTags:
type: array
description: "Click-URL params appended to the ad's destination as `url_tags` (e.g. utm_source). Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded."
items:
type: object
required: [key, value]
properties:
key: { type: string }
value: { type: string }
goal:
type: string
enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales, 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`.
- `job_applicants` requires a `platformSpecificData.jobs` creative.
- For `lead_generation` or `conversions` on LinkedIn, or to promote an existing post, use POST /v1/ads/boost.
**OpenAI Ads**
- Only `traffic`, `awareness`, and `conversions` are supported (other goals return 400). Maps to OpenAI's `bidding_type` (clicks, impressions, conversions respectively). `conversions` requires an active conversion event setting on the account; create a tracking tag with `defaultEventType` via the tracking-tags API (`POST /v1/accounts/{accountId}/tracking-tags`), or configure a conversion event in OpenAI Ads Manager, or the request returns 422.
optimizationGoal: { type: string, description: "Meta only. Explicit ad-set `optimization_goal` (e.g. `LANDING_PAGE_VIEWS`, `LINK_CLICKS`, `REACH`, `IMPRESSIONS`, `OFFSITE_CONVERSIONS`, `THRUPLAY`, `LEAD_GENERATION`). Overrides the default derived from `goal` (e.g. `traffic` defaults to `LINK_CLICKS`). Forwarded verbatim to Meta, which validates compatibility with the campaign objective and rejects incompatible combinations." }
billingEvent: { type: string, description: "Meta only. Explicit ad-set `billing_event`. Defaults to `IMPRESSIONS`. Forwarded verbatim to Meta, which validates compatibility with the optimization goal." }
buyingType: { type: string, enum: [AUCTION, RESERVED], description: "Meta only. RESERVED = Reach & Frequency: requires `rfPredictionId` (a RESERVED prediction from /v1/ads/rf-predictions + /reserve). Budget, schedule and pricing come from the reservation, so budgetAmount/budgetType are not required and bid fields are ignored. Only the plain single-ad shape (no creatives[], adSetId, existingCampaignId or dynamicCreative)." }
rfPredictionId: { type: string, description: "Meta only. The RESERVED prediction id the R&F ad set runs on (reserving mints a new id — pass that one). Requires buyingType RESERVED." }
creativeFeatures:
type: object
additionalProperties: { type: string, enum: [OPT_IN, OPT_OUT] }
description: 'Meta only. Advantage+ creative enhancements: a partial map of Meta creative feature keys (snake_case, e.g. enhance_cta, image_brightness_and_contrast, text_optimizations) to enroll status, forwarded as degrees_of_freedom_spec.creative_features_spec. Meta validates the keys; unspecified features default to OPT_OUT. The legacy standard_enhancements bundle is deprecated by Meta and rejected.'
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 — Meta rejects it there."
validateOnly:
type: boolean
description: 'Meta only, single standalone shape only (no creatives[], adSetId, or RESERVED). Dry-run: each node runs Meta''s execution_options validate_only and NOTHING is created or persisted. Children need real parents, so a fresh tree validates the campaign + creative (the ad set needs its campaign to exist — pass existingCampaignId to validate it too; the ad itself is never validatable pre-create). A Meta validation failure returns the 400 verbatim; success returns 200 with per-node results instead of an ad.'
budgetAmount: { type: number, description: "Budget in WHOLE currency units (USD: 50 = $50.00), NOT cents — Meta's own Marketing API takes this same number in minor units, so it is an easy and expensive mix-up. Required on legacy + multi-creative shapes. Inherited on attach. OpenAI Ads requires a $1 minimum (its budget is lifetime-only, see budgetType)." }
budgetType: { type: string, enum: [daily, lifetime], description: "Required on legacy + multi-creative shapes. Inherited on attach. OpenAI Ads accepts lifetime only (no daily-budget concept on the platform); sending daily returns 422. OpenAI Ads lifetime budgets require `endDate` to give the lifetime cap a spend window." }
status:
type: string
enum: [ACTIVE, PAUSED]
description: "Meta and TikTok. Publish state of the created entities. Omitted or ACTIVE publishes live (default, back-compat); PAUSED creates them paused 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`. On TikTok the whole campaign > ad group > ad hierarchy stays paused."
campaignStatus:
type: string
enum: [ACTIVE, PAUSED]
description: "Meta only. 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.
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/Twitter. Max: Meta=255, Google=30, Pinterest=100, LinkedIn=400, OpenAI=50 (min 3). On LinkedIn this is the ad's headline (the bold text on the creative); for traffic ads it's the link card title. On OpenAI Ads this is the chat card's title." }
longHeadline: { type: string, maxLength: 90, description: "Google Display only — defaults to `headline` if omitted. On LinkedIn, reused as the optional secondary description text on traffic (link) ads; omitted if not provided." }
body: { type: string, description: "Required on legacy + attach shapes. For X/Twitter this is the tweet text (max 280 chars including a ~24-char URL when `linkUrl` is set). On LinkedIn this is the post commentary (the intro text shown above the ad). On OpenAI Ads this is the chat card's body text. Max: Google=90, Pinterest=500, OpenAI=100." }
description: { type: string, maxLength: 255, description: "Meta only (facebook/instagram). Link description — the secondary text shown below the headline (Meta's link_data.description; on video creatives mapped to video_data.link_description). When omitted, Meta auto-pulls the destination URL's OpenGraph description. Applies on legacy, attach, and placementAssets shapes; for multi-creative use creatives[].description (this field is the shared fallback). For multi-text variations use dynamicCreative.descriptions instead." }
callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW], description: "Required on legacy + attach shapes for Meta. Honoured on TikTok (passes through to the Spark Ad creative's `call_to_action`) and on LinkedIn (the CTA button on the ad; defaults to LEARN_MORE when `linkUrl` is set). LinkedIn accepts: LEARN_MORE, SIGN_UP, DOWNLOAD, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, SHOP_NOW, BUY_NOW. Ignored by Google, Pinterest, and X/Twitter." }
linkUrl: { type: string, format: uri, description: "Required on legacy + attach shapes (skip for multi-creative). On LinkedIn it's the ad's destination URL; required for `traffic` ads, optional for `engagement` / `awareness`. NOT required when `goal` is `lead_generation` (the ad opens a Lead Gen form instead of a destination). On LinkedIn, `imageUrl` + `linkUrl` publishes an ARTICLE-content creative; this is LinkedIn's article ad format, with the image as thumbnail and `longHeadline` as description. Required for OpenAI Ads (the chat card's target_url)." }
leadGenFormId: { type: string, description: "Meta Lead Gen forms only (facebook/instagram). The leadgen_forms ID to attach to the ad's creative — create one via POST /v1/ads/lead-forms. REQUIRED when `goal` is `lead_generation`, and on every ATTACH (`adSetId`) call that targets a lead ad set (the form attaches per-ad; Meta rejects a formless ad in a lead ad set). Ignored otherwise. The ad set's promoted_object.page_id + LEAD_GENERATION optimization + destination_type ON_AD are derived automatically from the goal. Both `placementAssets` (per-placement creative) and `dynamicCreative` (multi-text / multi-asset pool, e.g. multiple headlines and primary texts) ARE supported on instant-form lead ads — the form is attached for you, and for `dynamicCreative` the ad set is created as a Dynamic Creative ad set automatically (Meta requires that for any multi-text feed; there is no non-DCO multi-text path). Send a single `imageUrls` (or `videoUrls`) entry plus your text variations to get Meta's \"Multiple Text Options\" behavior on a lead ad." }
imageUrl: { type: string, format: uri, description: "Image creative for Meta/Google/Pinterest/LinkedIn on legacy + attach shapes (mutually exclusive with `video`). Required for LinkedIn ads unless `video` is set. Not required for Google Search campaigns. For TikTok, this field carries the VIDEO URL (the TikTok ads endpoint is video-only; the field retains the `imageUrl` name for cross-platform consistency). Ignored for X/Twitter. For Google Display, treated as the landscape image (alias of `images.landscape`); supply `images.square` alongside or the request is rejected. For LinkedIn the image is uploaded to LinkedIn under the authoring Company Page (see `organizationId`); recommended ratio 1.91:1 (e.g. 1200×627). Required for OpenAI Ads (uploaded as the chat card's image; OpenAI has no video ad format)." }
images:
type: object
description: "Google Display (Responsive Display Ads) only. Google RDA requires both a landscape (1.91:1) and a square (1:1) marketing image; sending only one is rejected upstream as 'Too few.' (NOT_ENOUGH_*_MARKETING_IMAGE_ASSET). Supply both URLs here. Either this field or the legacy `imageUrl` can provide the landscape, but `square` has no legacy counterpart so it must be set here for Display."
properties:
landscape: { type: string, format: uri, description: "Landscape 1.91:1 marketing image URL (e.g. 1200x628). Also accepted via the top-level `imageUrl` for backward compatibility." }
square: { type: string, format: uri, description: "Square 1:1 marketing image URL (e.g. 1080x1080). Required for Google Display." }
video:
type: object
description: "Meta (facebook, instagram) and LinkedIn. 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:
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 — passing `bidStrategy`
in attach mode returns 400. To change an existing ad set's
bid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive
with `creatives[]`.
The attached ad takes the full single-creative surface:
`headline`/`body`/`description`/`callToAction` plus either
`imageUrl`/`video` OR `placementAssets` (its own per-placement
Feed/Story assets) 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), TikTok, and
LinkedIn. On TikTok the `adSetId` is the ad group ID; the
new ad inherits the ad group's bid + budget + targeting.
On LinkedIn the `adSetId` is the LinkedIn Campaign ID
(numeric); we attach a new Creative to that Campaign, so
the Campaign's `platformSpecificData` bidding, targeting,
budget and schedule are inherited (passing those fields
returns 400).
existingCampaignId:
type: string
description: |
Meta + LinkedIn. On Meta: add the new ad set under this
EXISTING campaign instead of creating a new one
(multi-ad-set audience testing). The new ad set's budget
is matched to the campaign's mode automatically: for a
CBO campaign (campaign-level budget) omit
`budgetAmount`/`budgetType` — the campaign owns the
budget; for an ABO campaign pass them (they go on the new
ad set). On LinkedIn: create a new Campaign (and its
Creative) under this EXISTING CampaignGroup. On failure
only the entities we authored are cleaned up; the
pre-existing parent is left untouched and is never
(re)activated. Mutually exclusive with `adSetId` and
`creatives[]`.
existingCreativeId:
type: string
description: |
Meta only. Reuse an EXISTING ad creative by id instead of
building a new one from the copy/media fields (which are then
ignored). Combine with `existingCampaignId` to build a
multi-ad-set campaign that shares one creative. Mutually
exclusive with `creatives[]`, `dynamicCreative`, and
`placementAssets`. The creative id used is returned as
`creativeId` on the create response.
businessName: { type: string, maxLength: 25, description: "Google Display only" }
boardId: { type: string, description: "Pinterest only. Board ID (auto-creates if not provided)." }
organizationId: { type: string, description: "LinkedIn only. The Company Page that authors the Direct Sponsored Content (\"dark\") post backing the ad — accepts a numeric organization ID or a full `urn:li:organization:N` URN. Required unless the resolved `accountId` is a connected LinkedIn Company-Page account (defaults to that page) or the LinkedIn ad account is org-owned (defaults to the account's owning organization). The authenticated member must be an ADMINISTRATOR or DIRECT_SPONSORED_CONTENT_POSTER of this page (and the page must be associated with the ad account), or LinkedIn returns 403. Ignored by every other platform." }
targeting:
description: |
Nested targeting object — the same TargetingSpec shape as `POST /v1/ads/boost`,
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. Merged
UNDER the flat inline targeting fields below: `savedTargetingId` < `targeting` <
flat fields (a flat field present on the body replaces the nested value entirely).
Both forms are equivalent; use whichever your integration already builds.
allOf:
- $ref: '#/components/schemas/TargetingSpec'
countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no other geo targeting (flat or nested `targeting`) is provided. (LinkedIn and OpenAI Ads currently honour country-level targeting only; any other targeting field returns 400 for OpenAI Ads.)" }
cities:
type: array
description: |
City-level geo targeting (Meta and TikTok). Each city is targeted by the platform's opaque `key` (the city ID) which can be looked up via `GET /v1/ads/targeting/search?dimension=geo&q=<name>&countryCode=<ISO>`. Optional `radius` + `distance_unit` (Meta only) extend the targeting beyond the city limits (e.g. radius 25 km around the city center). Both must be set together, or both omitted (Meta defaults to ~16 km when omitted).
On Meta, cannot overlap with the same country in `countries` (Meta returns a "locations overlap" error). Either drop the country or scope it to a different country. On TikTok, keys are numeric location ids and can be sent without `countries`.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Meta city ID, from /v1/ads/targeting/search results." }
radius: { type: number, description: "Optional radius around the city. Must be set together with distance_unit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng)." }
distance_unit: { type: string, enum: [mile, kilometer], description: "Unit for radius. Required if radius is set." }
regions:
type: array
description: |
Region-level (state/province) geo targeting (Meta and TikTok). Each region is targeted by the platform's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?dimension=geo&q=<name>&countryCode=<ISO>`.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Platform region ID, from /v1/ads/targeting/search results." }
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
zips:
type: array
description: "Postal/ZIP geo targeting. `key` is the platform's postal location ID from /v1/ads/targeting/search?dimension=geo&geoType=zip. Supported on Meta, Google, TikTok, Pinterest, X."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
metros:
type: array
description: "DMA / metro-area geo targeting (Meta and TikTok). `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro (TikTok metros appear as type `metro`, e.g. the New York DMA)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
customLocations:
type: array
description: "Point-radius (lat/lng) geo targeting. Meta only (custom_locations). Rejected on platforms without radius support."
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string }
behaviors:
type: array
description: "Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok. Each must include id."
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
incomeTier:
type: string
enum: [top_5, top_10, top_10_25, top_25_50]
description: |
Normalized household-income tier. Meta and TikTok express all four; Google maps only
`top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible
with housing/employment/credit `specialAdCategories`.
languages: { type: array, items: { type: string }, description: "Language codes restricting the audience by language. On Meta, ISO 639-1 codes (e.g. ['en'], ['de']); a bare code targets all regional variants (\"en\" = all English), or use a region-qualified code for a specific one (\"en_GB\", \"pt_BR\", \"zh_TW\"). Unknown codes are rejected. Other ad platforms use their own language-code systems." }
placements:
type: object
description: |
Meta only. Manual ad placements. Omit for automatic placements (Meta's default,
recommended for most cases — Meta optimises delivery across all eligible surfaces).
When set, restricts delivery to the chosen surfaces, mapped onto the ad set's
`targeting.{publisher_platforms, facebook_positions, instagram_positions,
messenger_positions, audience_network_positions, threads_positions,
whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta
additionally enforces co-selection rules (e.g. some positions require their parent
publisher platform) and returns an actionable error which we surface. Non-Meta
platforms reject this field.
properties:
publisherPlatforms:
type: array
items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network, whatsapp] }
description: "Top-level platforms to deliver on. A position field below is only honoured when its parent platform is included here."
facebookPositions:
type: array
items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
instagramPositions:
type: array
items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
messengerPositions:
type: array
items: { type: string, enum: [messenger_home, sponsored_messages, story] }
audienceNetworkPositions:
type: array
items: { type: string, enum: [classic, rewarded_video] }
threadsPositions:
type: array
items: { type: string, enum: [threads_stream] }
whatsappPositions:
type: array
items: { type: string, enum: [status] }
devicePlatforms:
type: array
items: { type: string, enum: [mobile, desktop] }
description: "Restrict by device. Omit to deliver on both mobile and desktop."
savedTargetingId:
type: string
description: |
ID of a `saved_targeting` audience (created via POST /v1/ads/audiences). When set, its stored
TargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you
reuse a named targeting preset without re-sending every field.
rawTargeting:
type: object
additionalProperties: true
description: |
Meta only. A raw Meta-native targeting spec (snake_case: `geo_locations`, `age_min`,
`excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, `user_os`,
`wireless_carrier`, business places, etc.) — exactly the shape `GET /v1/ads/{adId}` returns for
external ads. Sent alone it reaches the ad set VERBATIM (the clone-a-campaign's-targeting-exactly
path). Meta validates and surfaces any errors.
Can be combined with the camelCase targeting fields (countries/regions/cities/interests/ageMin/...,
`targeting`, `savedTargetingId`, `audienceId`): rawTargeting is the BASE layer and the built
camelCase spec is merged on top, key by key, with the camelCase side winning on collision (the
camelCase precedence chain stays `savedTargetingId` < `targeting` < flat fields). The merge goes
one level deep inside `geo_locations` and `excluded_geo_locations`: built sub-keys win, raw-only
sub-keys such as `location_types` survive alongside built `countries`. Array values
(`flexible_spec`, ...) are replaced as a WHOLE key when the camelCase spec builds them, never
element-merged. When rawTargeting is present the defaults the camelCase builder normally injects
(US geo, `targeting_automation.advantage_audience: 0`) are suppressed, so raw's values are not
clobbered — include `targeting_automation` in the raw spec (or send `advantageAudience`) as Meta
requires it on create. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those
are separate fields, not part of targeting).
specialAdCategories:
type: array
description: |
Meta only. Declares the ad's special category, required for housing, employment, credit, or
political/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special
category disables income/zip targeting on Meta.
items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, FINANCIAL_PRODUCTS_SERVICES, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING] }
specialAdCategoryCountry:
type: array
description: |
Meta (metaads) only. 2-letter ISO country codes the special ad category applies to. Requires
specialAdCategories to be set (400 otherwise). Ignored when joining an existing campaign via
existingCampaignId (the existing campaign's category/country already governs it).
items: { type: string }
endDate: { type: string, format: date-time, description: Required for lifetime budgets }
startDate:
type: string
format: date-time
description: |
Meta only. Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the
ad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime
budgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already
available on `POST /v1/ads/boost`.)
instagramAccountId:
type: string
description: |
Meta only. Override the Instagram account the ad is delivered as — pass an Instagram
Business Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`.
When omitted we 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). Meta limits: ≤10 images or ≤10 videos, ≤5 bodies / titles / descriptions.
properties:
imageUrls:
type: array
minItems: 1
maxItems: 10
items: { type: string, format: uri }
description: "Pool of image URLs (1-10). Uploaded to the ad account and referenced by hash in the asset feed. Mutually exclusive with `videoUrls`."
videoUrls:
type: array
minItems: 1
maxItems: 10
items: { type: string, format: uri }
description: "Pool of video URLs (1-10). Uploaded to the ad account and referenced by video id in the asset feed. No thumbnails are needed: Meta auto-generates a poster per video. Mutually exclusive with `imageUrls`; `adFormat` defaults to SINGLE_VIDEO."
bodies:
type: array
maxItems: 5
items: { type: string }
description: "Primary-text variations (the body copy)."
titles:
type: array
maxItems: 5
items: { type: string }
description: "Headline variations."
descriptions:
type: array
maxItems: 5
items: { type: string }
description: "Description (link caption) variations."
linkUrls:
type: array
items: { type: string, format: uri }
description: "Destination URL variations. At least one is required unless `goal` is `lead_generation`."
callToActionTypes:
type: array
items: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW] }
description: "CTA-button variations. Required."
adFormat:
type: string
enum: [SINGLE_IMAGE, CAROUSEL_IMAGE, SINGLE_VIDEO]
description: "Asset-feed ad format. Must match the pool: SINGLE_IMAGE / CAROUSEL_IMAGE require `imageUrls`, SINGLE_VIDEO requires `videoUrls` (400 otherwise). Defaults to SINGLE_IMAGE with `imageUrls`, SINGLE_VIDEO with `videoUrls`."
carouselCards:
type: array
minItems: 2
maxItems: 10
description: |
Meta only. Hand-built carousel: 2-10 authored cards in DETERMINISTIC order, mapped to
the creative's `link_data.child_attachments`. Unlike `dynamicCreative`,
you control the card order and per-card copy/link. Requires top-level `body`,
`linkUrl` and `callToAction`. 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`, `leadGenFormId` and goal
`catalog_sales`.
items:
type: object
required: [imageUrl]
properties:
imageUrl: { type: string, format: uri, description: "Card image; uploaded to the ad account and referenced by hash." }
linkUrl: { type: string, format: uri, description: "Card destination URL. Defaults to the top-level linkUrl." }
headline: { type: string, maxLength: 255, description: "Card headline, shown below the card image." }
description: { type: string, maxLength: 255, description: "Card description, shown under the headline." }
callToAction: { type: string, description: "Card CTA override. Defaults to the top-level callToAction; same enum." }
defaultLocale:
type: string
description: "Meta only. Language the top-level copy is written in (e.g. `en`, `pt_BR`), used by the `translations` default rule. Defaults to `en`. Meta rejects a language asset feed whose default rule carries no locales of its own. Must NOT also appear as an entry in `translations`."
translations:
type: array
minItems: 1
maxItems: 10
description: |
Meta only. Multi-language ads (Dynamic Language Optimization): ONE ad carrying
per-locale copy and, optionally, per-locale media — the "Languages" toggle in Ads
Manager. Keeps social proof (likes/comments/shares) on a SINGLE post instead of
splitting it across one ad per language.
The ad's top-level copy is the DEFAULT shown to every locale you do NOT list,
and it counts as one of the language variants.
IMPORTANT, and the opposite of what you might expect: text does NOT inherit.
Every entry must carry its own `headline`, `body` AND `description`, and all of
them must be DISTINCT from each other and from the ad's top-level copy. Meta
deduplicates identical strings inside the asset feed, so two locales sharing a
string collapse into one asset and the create fails with a misleading "Too few
... texts provided in asset creation" (subcode 1885817) that names a field which
is actually present. We validate this before calling Meta and return a 400
naming the offending locale and field. `description` is therefore effectively
required on the ad whenever `translations` is present, even though it is
optional otherwise.
Do NOT list `defaultLocale` inside `translations`: Meta rejects the duplicate
with "The language asset feed includes an unsupported targeting field"
(subcode 1885985).
Media DOES inherit and is uploaded once when shared, and `linkUrl` inherits
too: each locale may name its own landing page and unlisted locales fall back
to the ad's top-level `linkUrl`. Note that Meta enforces
Dynamic Creative image dimensions on language feeds, so an `imageUrl` that
works on a normal ad may be rejected with "The following images have invalid
dimensions for Dynamic Creative" (subcode 1885558). Video is not affected.
Mutually exclusive with `dynamicCreative`, `placementAssets`, `carouselCards`,
`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[]`. Shared copy (headline, body, link,
CTA) comes from the top-level single-creative fields since only the asset varies by
placement. Each rule's `placements` accepts the same fields as the top-level
`placements` object; Meta enforces co-selection rules and returns an actionable error.
Note on text rendering: Meta suppresses primary text and headline on fullscreen
placements (Stories and Reels) in actual ad delivery; the fields are accepted and
the ad publishes, but the copy is not shown to users. For visible copy on those
placements, bake the text into the creative image or video itself.
A block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad
format). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode:
`defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl`
posters; Meta auto-generates when omitted). Exactly one catch-all default is required.
required: [rules]
properties:
defaultImageUrl:
type: string
format: uri
description: "Image mode. Catch-all image for any placement no rule matches. Required in image mode (Meta mandates a default rule)."
defaultVideoUrl:
type: string
format: uri
description: "Video mode. Catch-all video for any placement no rule matches. Required in video mode."
defaultThumbnailUrl:
type: string
format: uri
description: "Video mode (optional). Poster image for the default video; Meta auto-generates one when omitted."
rules:
type: array
minItems: 1
maxItems: 10
description: "One entry per placement group you want to pin a specific asset to."
items:
type: object
required: [placements]
properties:
imageUrl:
type: string
format: uri
description: "Image mode. The image to deliver for this rule's placements."
videoUrl:
type: string
format: uri
description: "Video mode. The video to deliver for this rule's placements."
thumbnailUrl:
type: string
format: uri
description: "Video mode (optional). Poster image for this rule's video; auto-generated when omitted."
placements:
type: object
description: "Placements this asset is pinned to. At least one field must be set (an empty rule is invalid — that role is served by the default asset). Same enums as the top-level `placements` object."
properties:
publisherPlatforms:
type: array
items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network, whatsapp] }
facebookPositions:
type: array
items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
instagramPositions:
type: array
items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
messengerPositions:
type: array
items: { type: string, enum: [messenger_home, sponsored_messages, story] }
audienceNetworkPositions:
type: array
items: { type: string, enum: [classic, rewarded_video] }
threadsPositions:
type: array
items: { type: string, enum: [threads_stream] }
whatsappPositions:
type: array
items: { type: string, enum: [status] }
devicePlatforms:
type: array
items: { type: string, enum: [mobile, desktop] }
audienceId: { type: string, description: Custom audience ID for targeting }
campaignType: { type: string, enum: [display, search], default: display, description: Google only }
keywords: { type: array, items: { type: string }, description: "Google Search only. BROAD-match keywords on the new ad group (first 20)." }
negativeKeywords: { type: array, items: { type: string, maxLength: 80 }, description: "Google Search only; other platforms return 400. BROAD-match negative keywords on the new ad group. Editable later via PUT /v1/ads/{adId} targeting.negativeKeywords." }
additionalHeadlines: { type: array, items: { type: string }, description: "Google Search RSA only. Extra headlines." }
additionalDescriptions: { type: array, items: { type: string }, description: "Google Search RSA only. Extra descriptions." }
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 — maps 1:1 to Meta's
ad-set `attribution_spec`. Only honored for conversion goals (`conversions`,
`lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement.
Omit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the
valid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view
windows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside
`VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos
surface as a Meta 400.
Example: `[{ "eventType": "CLICK_THROUGH", "windowDays": 7 }, { "eventType": "VIEW_THROUGH", "windowDays": 1 }]`
gender: { type: string, enum: [all, male, female], default: all, description: "Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
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.
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.
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 — 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).
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 — 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`.
promotedObject:
type: object
description: |
What the ad optimises against. Behaviour depends on the platform.
**Meta**: forwarded to the ad set's `promoted_object` (snake-cased).
Required for goals whose ad-set optimization_goal points at a specific
event/page/app (without it Meta rejects the ad-set create with
`error_subcode: 1815430` "Please select a promoted object for your ad set"):
- `goal: conversions` / `lead_conversion` (OFFSITE_CONVERSIONS): requires `pixelId` + `customEventType`, or `customConversionId` when optimising against a Custom Conversion (the conversion carries its own event definition). For a pixel CUSTOM event (one you named yourself in CAPI/Events Manager), send `customEventType: OTHER` + `customEventStr` with the event name.
- `goal: app_promotion` (APP_INSTALLS): requires `applicationId` + `objectStoreUrl`
- `goal: lead_generation` (LEAD_GENERATION): `pageId` is auto-filled from the connected Page when omitted
Other Meta goals (engagement, traffic, awareness, video_views) ignore this field.
**TikTok**: 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: Catalog ID for catalog/Advantage+ Shopping campaigns.
productSetId:
type: string
description: Product Set ID inside the catalog.
offlineConversionDataSetId:
type: string
description: 'Meta only. Offline event set (dataset) to optimise toward. Post-merger these are datasets: the id is the dataset id (for pixel-backed datasets, the pixel id).'
whatsappPhoneNumber:
type: string
description: 'Meta only. WhatsApp number on messaging-destination ad sets.'
additionalProperties: false
responses:
'200':
description: 'validateOnly dry-run passed — nothing was created'
content:
application/json:
schema:
type: object
properties:
validateOnly: { type: boolean, description: 'Always true in a validate-only response.' }
results:
type: array
items:
type: object
properties:
node: { type: string, enum: [campaign, adSet, creative, ad] }
status: { type: string, enum: [validated, skipped] }
reason: { type: string, description: "Why the node could not be validated (only on skipped)." }
message: { type: string }
'201':
description: Ad(s) created
content:
application/json:
schema:
oneOf:
- type: object
description: "Legacy + attach shapes — one ad returned."
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
- type: object
description: "Multi-creative shape — N ads returned sharing platformCampaignId / platformAdSetId."
properties:
ads:
type: array
items: { $ref: '#/components/schemas/Ad' }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
message: { type: string }
'400':
description: 'Missing required fields, invalid values, non-Meta platform used with creatives[] / adSetId, or a Meta validateOnly validation failure (verbatim)'
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'422':
description: Platform ads connection required (TikTok Ads, X Ads) or missing linked account
'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.
/v1/ads/leads:
get:
x-resource-group: "contacts"
operationId: listLeads
tags: ["Lead Gen"]
x-platforms: ["meta", "linkedin"]
summary: List submitted leads
description: >
Returns submitted Lead Gen leads for your team, newest-first, with
keyset pagination on `cursor`. For Meta (default) leads are served from
the persisted cache, ingested in real time from the `leadgen` webhook.
When `accountId` is a LinkedIn ads account, leads are fetched live from
LinkedIn's `leadFormResponses` (LinkedIn has no webhook and enforces
90-day retention, so nothing is persisted) and `adAccountId` is required.
Reading LinkedIn responses needs the `r_marketing_leadgen_automation`
permission; accounts connected before it was added must reconnect.
Requires the Ads add-on.
parameters:
- { name: formId, in: query, schema: { type: string }, description: Filter to a single lead form. }
- { name: accountId, in: query, schema: { type: string }, description: Filter to a single connected account. LinkedIn ads accounts switch to the live fetch. }
- { name: adAccountId, in: query, schema: { type: string }, description: "LinkedIn only: the LinkedIn ad account id whose responses to read (owner-scoped finder)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
- { name: since, in: query, schema: { type: integer }, description: Unix seconds; only leads created at/after this timestamp. }
- { name: cursor, in: query, schema: { type: string }, description: "Keyset cursor from a previous response's pagination.cursor (Meta: AdLead id; LinkedIn: numeric start offset)." }
responses:
'200':
description: Lead list.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
leads:
type: array
items:
type: object
properties:
id: { type: string, description: Zernio lead id. }
leadgenId: { type: string, description: 'Meta lead id. 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. }
/v1/ads/lead-forms:
get:
x-resource-group: "ads"
operationId: listLeadForms
tags: ["Lead Gen"]
x-platforms: ["meta", "linkedin"]
summary: List lead forms
description: >
Lists the Lead Gen forms owned by the account. Meta: forms on the
connected Facebook Page. LinkedIn: forms owned by the ad account's
Company Page — pass `adAccountId` (LinkedIn forms are org-owned).
Requires the Ads add-on.
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: Connected facebook or linkedin ads account id. }
- { name: adAccountId, in: query, schema: { type: string }, description: "LinkedIn only: the LinkedIn ad account id (used to resolve the owning organization). Required for LinkedIn." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
- { name: cursor, in: query, schema: { type: string } }
responses:
'200':
description: Forms list.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
forms: { type: array, items: { type: object } }
pagination: { type: object, properties: { hasMore: { type: boolean }, cursor: { type: [string, "null"] } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on required. }
post:
x-resource-group: "ads"
operationId: createLeadForm
tags: ["Lead Gen"]
x-platforms: ["meta", "linkedin"]
summary: Create a lead form
description: >
Creates a Lead Gen form. The form content goes inside
`platformSpecificData` for both platforms (the shape is selected by the
accountId's platform). Meta: created on the connected Facebook Page
(POST /{page-id}/leadgen_forms); the old top-level Meta fields
(questions, thankYou*, contextCard, …) are DEPRECATED but still
accepted while platformSpecificData is absent — mixing both shapes is
a 400. LinkedIn: created on the ad account's Company Page. NOT
idempotent — a retry creates a second form. Meta prefilled question
types (EMAIL, PHONE, FULL_NAME, …) must omit label/key; CUSTOM
questions require both. LinkedIn exposes only free-text and
multiple-choice questions via API (prefilled-from-profile fields are
Campaign Manager UI-only). Requires the Ads add-on.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, privacyPolicyUrl]
properties:
accountId: { type: string }
name: { type: string, maxLength: 200 }
questions:
type: array
minItems: 1
deprecated: true
description: "Deprecated (Meta legacy shape): use platformSpecificData.questions."
items:
type: object
required: [type]
properties:
type: { type: string, description: "EMAIL, PHONE, FULL_NAME, FIRST_NAME, LAST_NAME, CUSTOM, …" }
key: { type: string, description: "CUSTOM questions only." }
label: { type: string, description: "CUSTOM questions only." }
options: { type: array, items: { type: object, properties: { key: { type: string }, value: { type: string } } } }
inline_context: { type: string }
privacyPolicyUrl: { type: string, format: uri }
privacyPolicyLinkText: { type: string, maxLength: 70, deprecated: true, description: "Deprecated: use platformSpecificData.privacyPolicyLinkText." }
followUpActionUrl: { type: string, format: uri, deprecated: true, description: "Deprecated: use platformSpecificData.followUpActionUrl." }
locale: { type: string, example: EN_US, deprecated: true, description: "Deprecated: use platformSpecificData.locale." }
thankYouTitle: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouTitle." }
thankYouBody: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouBody." }
thankYouButtonText: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouButtonText." }
thankYouButtonType: { type: string, example: VIEW_WEBSITE, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouButtonType." }
thankYouWebsiteUrl: { type: string, format: uri, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouWebsiteUrl." }
isOptimizedForQuality: { type: boolean, deprecated: true, description: "Deprecated: use platformSpecificData.isOptimizedForQuality." }
platformSpecificData:
description: "Form content; the shape is selected by the accountId's platform. Unknown fields are a 400 (strict-parsed)."
oneOf:
- type: object
title: MetaLeadFormPlatformData
required: [questions]
properties:
questions:
type: array
minItems: 1
items:
type: object
required: [type]
properties:
type: { type: string, description: "EMAIL, PHONE, FULL_NAME, FIRST_NAME, LAST_NAME, CUSTOM, …" }
key: { type: string, description: "CUSTOM questions only." }
label: { type: string, description: "CUSTOM questions only." }
options: { type: array, items: { type: object, properties: { key: { type: string }, value: { type: string } } } }
inline_context: { type: string }
privacyPolicyLinkText: { type: string, maxLength: 70 }
followUpActionUrl: { type: string, format: uri }
locale: { type: string, example: EN_US }
thankYouTitle: { type: string }
thankYouBody: { type: string }
thankYouButtonText: { type: string }
thankYouButtonType: { type: string, example: VIEW_WEBSITE }
thankYouWebsiteUrl: { type: string, format: uri }
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 }
- 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
parameters:
- { name: formId, in: path, required: true, schema: { type: string }, description: "Numeric form id (Meta leadgen_form id or LinkedIn leadForm id)." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Connected facebook or linkedin ads account id (selects the platform)." }
responses:
'200':
description: Form metadata.
content:
application/json:
schema: { type: object, properties: { status: { type: string }, form: { type: object } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "ads"
operationId: archiveLeadForm
tags: ["Lead Gen"]
x-platforms: ["meta", "linkedin"]
summary: Archive a lead form
description: "Neither platform hard-deletes a form; this archives it (Meta status=ARCHIVED; LinkedIn state=ARCHIVED via PARTIAL_UPDATE)."
parameters:
- { name: formId, in: path, required: true, schema: { type: string }, description: "Numeric form id (Meta leadgen_form id or LinkedIn leadForm id)." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Connected facebook or linkedin ads account id (selects the platform)." }
responses:
'200':
description: Archived.
content:
application/json:
schema: { type: object, properties: { status: { type: string }, formId: { type: string }, archived: { type: boolean } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/lead-forms/{formId}/leads:
get:
x-resource-group: "contacts"
operationId: listFormLeads
tags: ["Lead Gen"]
x-platforms: ["meta"]
summary: List leads for a single form
description: >
Returns leads for one form. Serves persisted leads (ingested via the
leadgen webhook) when available, falling back to a live Graph read.
parameters:
- { name: formId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
- { name: cursor, in: query, schema: { type: string } }
- { name: since, in: query, schema: { type: integer }, description: Unix seconds. }
responses:
'200':
description: Leads for the form.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
leads:
type: array
items:
type: object
properties:
id: { type: string }
createdTime: { type: [string, "null"] }
adId: { type: [string, "null"] }
formId: { type: string }
fields: { type: object, additionalProperties: { type: string } }
fieldData: { type: array, items: { type: object } }
pagination: { type: object, properties: { hasMore: { type: boolean }, cursor: { type: [string, "null"] } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/lead-forms/{formId}/test-leads:
post:
x-resource-group: "contacts"
operationId: createTestLead
tags: ["Lead Gen"]
x-platforms: ["meta"]
summary: Create a test lead
description: >
Submits a test lead against the form (POST /{form-id}/test_leads) to
exercise retrieval without waiting for real ad impressions. Meta allows
one test lead per form at a time.
parameters:
- { name: formId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, fieldData]
properties:
accountId: { type: string }
fieldData:
type: array
minItems: 1
items:
type: object
required: [name, values]
properties:
name: { type: string }
values: { type: array, items: { type: string }, minItems: 1 }
responses:
'200':
description: Test lead created.
content:
application/json:
schema: { type: object, properties: { status: { type: string }, testLead: { type: object, properties: { id: { type: string } } } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/images:
post:
x-resource-group: "ads"
operationId: uploadAdImage
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Upload an ad image from base64
description: |-
Uploads raw image bytes to the Meta ad account's image library — for callers whose
creatives aren't hosted at a public URL. Returns the image `hash` (Meta's identifier for
the asset) and the Meta-hosted `url`, which can be used directly as `imageUrl` on the
create endpoints. Max 30 MB decoded.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, imageBase64]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
imageBase64: { type: string, description: "Raw base64 image bytes, or a full data URL (the data:image/...;base64, prefix is stripped)." }
filename: { type: string, description: "Optional filename shown in Meta's image library. Defaults to ad_image.jpg." }
responses:
'201':
description: Image uploaded
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
image:
type: object
properties:
hash: { type: string, description: "Meta image hash, reusable wherever image_hash is accepted." }
url: { type: string, description: "Meta-hosted image URL; usable as imageUrl on the create endpoints." }
'400': { description: "Invalid input, or Meta rejected the image" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
'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 }, description: "Comma-separated Graph field override (supports nested {} projections)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Ad images (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad image row (hash, url, name, width, height, status)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/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) used to resolve the Meta token." }
adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
videoUrl: { type: string, description: "Public https URL of the video; downloaded server-side (SSRF-guarded) before chunked upload. Provide exactly one of videoUrl or videoBase64." }
videoBase64: { type: string, description: "Raw base64 video bytes, or a full data URL (the data:video/...;base64, prefix is stripped). Capped by Vercel's body limit (~4.5 MB payload). Provide exactly one of videoUrl or videoBase64." }
filename: { type: string, description: "Optional filename shown alongside the upload session. Applied only when uploading via videoBase64." }
responses:
'201':
description: Video uploaded and ready
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
video:
type: object
properties:
id: { type: string, description: "Meta video id, reusable as video.id on POST /v1/ads/create and inside POST /v1/ads/preview creativeSpec." }
thumbnailUrl: { type: [string, "null"], description: "Meta-hosted poster URL if available; null when Meta has not produced a poster yet." }
'400': { description: "Invalid input, or Meta rejected the upload" }
'401': { $ref: '#/components/responses/Unauthorized' }
'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 }, description: "Comma-separated Graph field override (supports nested {} projections)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'200':
description: Ad 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:
'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: Social account ID }
responses:
'200':
description: Matching interests
content:
application/json:
schema:
type: object
properties:
interests:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
category: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/targeting/search:
get:
x-resource-group: "ads"
operationId: searchAdTargeting
tags: ["Ad Targeting"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest"]
summary: Search targeting options
description: |
Resolve a human-readable query into the platform's opaque targeting ids used in
the `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and
`interests`/`behaviors` entity ids) on `POST /v1/ads/create`,
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences.
The `dimension` param selects what is searched, `geo` (locations, further scoped
by `geoType`), `interest`, `behavior`, or `income`. Availability of each dimension
varies by platform (e.g. behaviours are Meta/TikTok only). Results are normalized
across platforms into a single shape, so the same client code consumes Meta,
TikTok, LinkedIn, X, Pinterest, and Google results.
TikTok geo searches return every matching level in one list (`type` is
`country`, `region`, `city`, `district`, or `metro` for DMA areas) —
`geoType` is not applied. Results are scoped to the advertiser's targetable
markets, and every id is usable in `regions`/`cities`/`metros` keys on
`POST /v1/ads/create`.
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. Every result has `type` set to `location`, and its id is a
`urn:li:geo:*` URN usable as a `regions[].key` on `POST /v1/ads/create`,
`POST /v1/ads/boost` and `POST /v1/ads/targeting/reach-estimate`.
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 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: "Social account ID (a connected account on the target ad platform)." }
- { name: q, in: query, required: true, schema: { type: string }, description: "Search query. For geo, the locality name only (no region/country suffix)." }
- { name: dimension, in: query, required: false, schema: { type: string, enum: [geo, interest, behavior, income], default: interest }, description: "What to search. `geo` resolves locations (scope further with `geoType`), `interest`/`behavior` resolve audience entities, `income` resolves income-tier options. Defaults to `interest` for backward compatibility with the deprecated /v1/ads/interests alias." }
- { name: geoType, in: query, required: false, schema: { type: string, enum: [all, country, region, city, subcity, neighborhood, place, zip, metro_area, geo_market], default: city }, description: "Only used when `dimension=geo`. The kind of location to resolve. `all` searches every type in one relevance-ranked call. Defaults to `city`." }
- { name: countryCode, in: query, required: false, schema: { type: string, minLength: 2, maxLength: 2 }, description: "ISO 3166-1 alpha-2 country code (e.g. NL) to scope a geo search." }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: "Maximum results to return." }
responses:
'200':
description: Matching targeting options (normalized)
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
required: [id, name, type]
properties:
id: { type: string, description: "The platform's opaque id. Use as a geo `key` (regions/cities/zips/metros) or an entity `id` (interests/behaviors) in TargetingSpec." }
name: { type: string, description: "Human-readable label." }
type: { type: string, description: "What the result is (e.g. city, region, country, zip, metro, location, interest, behavior, income)." }
path: { type: array, items: { type: string }, description: "Optional breadcrumb of parent labels (e.g. ['United States', 'California', 'Los Angeles']). Disambiguates same-named results." }
audienceSize: { type: [integer, "null"], description: "Optional estimated reachable users for this option, when the platform returns it." }
'400':
description: Missing or invalid query parameters
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404':
description: Account not found, or the platform does not support the requested dimension
/v1/ads/targeting/reach-estimate:
post:
x-resource-group: "ads"
operationId: estimateAdReach
tags: ["Ad Targeting"]
x-platforms: ["meta"]
summary: Estimate audience reach
description: |
Returns a normalized pre-flight audience-size estimate for a targeting spec,
before any campaign is created. Backed by each platform's native reach API
(Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`,
Pinterest `audience_sizing`).
Platforms without a usable pre-flight reach API (Google Search/Display, TikTok)
return `available: false` with no bounds, so clients can hide or grey out the
estimate rather than treat the absence as an error.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec]
properties:
accountId: { type: string, description: "Zernio social account ID on the target ad platform (the estimate runs against its platform)." }
adAccountId: { type: string, description: "Required. The platform ad-account ID the reach call runs against (Meta act_..., LinkedIn numeric sponsoredAccount ID, Pinterest ad-account ID, X account ID) - every backing reach API is scoped to one ad account. Get it from GET /v1/ads/accounts." }
spec:
allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
description: "The targeting spec to estimate. Same shape used by POST /v1/ads/create."
optimizationGoal:
type: string
description: |
Optional. The optimization goal the estimate should assume (platform's
own vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`).
Some platforms vary the estimate by goal; omit to use the platform default.
responses:
'200':
description: Normalized reach estimate
content:
application/json:
schema:
type: object
required: [available]
properties:
available: { type: boolean, description: "Whether a pre-flight estimate is available on this platform. False for Google and TikTok." }
lower: { type: [integer, "null"], description: "Lower bound of the estimated reachable audience. Present only when available." }
upper: { type: [integer, "null"], description: "Upper bound of the estimated reachable audience. Present only when available." }
daily: { type: [integer, "null"], description: "Optional estimated daily reach/results at the given budget, when the platform returns it." }
currency: { type: [string, "null"], description: "Currency of any monetary fields in the estimate, when applicable." }
estimateReady: { type: [boolean, "null"], description: "Meta only. False when Meta is still computing the estimate (the audience is too new); retry shortly." }
'400':
description: Missing required fields or a targeting field the platform cannot honour
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/targeting/bid-pricing:
post:
x-resource-group: "ads"
operationId: getLinkedInBidPricing
tags: ["Ad Targeting"]
x-platforms: ["linkedin"]
summary: Suggested bid and budget bounds
description: |
LinkedIn-only. Returns the suggested bid and bid limits for a targeting
spec, plus the daily-budget bounds LinkedIn will accept. Use it before
creating a campaign to pick a bid inside the allowed range and warn the
user if their daily budget is below the minimum. Wraps LinkedIn's
`adBudgetPricing` finder.
Non-LinkedIn accounts return `available: false` so clients can hide the
pricing UI without treating it as a failure.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec]
properties:
accountId: { type: string, description: "Zernio social account ID (LinkedIn)." }
adAccountId: { type: string, description: "LinkedIn ad account ID (numeric)." }
spec: { allOf: [{ $ref: '#/components/schemas/TargetingSpec' }], description: "Same targeting spec used by POST /v1/ads/create." }
campaignType: { type: string, enum: [TEXT_AD, SPONSORED_UPDATES, SPONSORED_INMAILS], description: "Defaults to SPONSORED_UPDATES." }
bidType: { type: string, enum: [CPM, CPC, CPV], description: "Defaults to CPM." }
matchType: { type: string, enum: [EXACT, AUDIENCE_EXPANDED], description: "Defaults to EXACT." }
currency: { type: string, minLength: 3, maxLength: 3, description: "ISO 4217, defaults to USD." }
objectiveType: { type: string, description: "LinkedIn objectiveType, e.g. WEBSITE_VISIT, LEAD_GENERATION, VIDEO_VIEW." }
optimizationTargetType: { type: string, description: "LinkedIn optimizationTargetType, e.g. MAX_CLICK, MAX_IMPRESSION." }
dailyBudget: { type: number, description: "Optional daily budget in whole account-currency units. LinkedIn refines the suggested bid to this budget." }
responses:
'200':
description: Pricing insights
content:
application/json:
schema:
type: object
required: [available]
properties:
available: { type: boolean }
pricing:
type: [object, "null"]
description: LinkedIn's adBudgetPricing element. Null when LinkedIn has no data for the combination.
properties:
bidLimits:
type: object
properties:
min: { $ref: '#/components/schemas/MoneyAmount' }
max: { $ref: '#/components/schemas/MoneyAmount' }
suggestedBid:
type: object
properties:
min: { $ref: '#/components/schemas/MoneyAmount' }
default: { $ref: '#/components/schemas/MoneyAmount' }
max: { $ref: '#/components/schemas/MoneyAmount' }
dailyBudgetLimits:
type: object
properties:
min: { $ref: '#/components/schemas/MoneyAmount' }
default: { $ref: '#/components/schemas/MoneyAmount' }
max: { $ref: '#/components/schemas/MoneyAmount' }
'400': { description: "Invalid targeting or unsupported objective/optimization/bid combination." }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: "Ads access required." }
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/targeting/supply-forecast:
post:
x-resource-group: "ads"
operationId: getLinkedInSupplyForecast
tags: ["Ad Targeting"]
x-platforms: ["linkedin"]
summary: Impressions, clicks and spend forecast
description: |
LinkedIn-only. Forecasted impressions, clicks, spend and ~20 other
metrics for a targeting spec over a time range. Wraps LinkedIn's
`adSupplyForecasts` finder.
Each returned series carries a `metricType` (IMPRESSION, CLICK, SPENDING,
MAX_POTENTIAL_BUDGET, COST_PER_MILLION_IMPRESSIONS, ...) and a
`granularity` (DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM). LinkedIn caps the
daily spending forecast at 1.2x the daily budget and returns 0 once the
total budget is exhausted.
Non-LinkedIn accounts return `available: false`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec, timeRangeStart, timeRangeEnd]
properties:
accountId: { type: string }
adAccountId: { type: string }
spec: { allOf: [{ $ref: '#/components/schemas/TargetingSpec' }] }
campaignType: { type: string, enum: [SPONSORED_UPDATES, SPONSORED_INMAILS, DYNAMIC], description: "Defaults to SPONSORED_UPDATES." }
timeRangeStart: { type: integer, description: "Unix ms. Must be in the future." }
timeRangeEnd: { type: integer, description: "Unix ms. Must be after start and within LinkedIn's max horizon." }
objectiveType: { type: string }
optimizationTarget: { type: string, description: "When set, the forecast assumes auto-bidding. When unset, competingBid is required." }
dailyBudget: { type: number, description: "Either dailyBudget or totalBudget is required." }
totalBudget: { type: number }
currency: { type: string, minLength: 3, maxLength: 3, description: "ISO 4217, defaults to USD." }
competingBid:
type: object
required: [bidType, amount]
description: "Required for manual-bid forecasts (when optimizationTarget is not set)."
properties:
bidType: { type: string, enum: [CPM, CPC, CPV] }
amount: { type: number }
enableAudienceNetwork: { type: boolean, description: "Defaults to false. Required true for connectedTelevisionOnly." }
enableAudienceExpansion: { type: boolean, description: "Defaults to false." }
connectedTelevisionOnly: { type: boolean, description: "Defaults to false." }
responses:
'200':
description: Forecast series
content:
application/json:
schema:
type: object
required: [available]
properties:
available: { type: boolean }
forecast:
type: array
items:
type: object
properties:
metricType: { type: string }
granularity: { type: string, enum: [DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM] }
timeSeries:
type: array
items:
type: object
properties:
timestamp: { type: integer }
value: { type: number }
adForecastRange:
type: object
properties:
lowEnd: { type: number }
highEnd: { type: number }
'400': { description: "Invalid targeting, missing budget, or LinkedIn forecast validation error (e.g. END_DATE_MAX_HORIZON_FOR_FORECAST)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: "Ads access required." }
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/catalogs:
get:
x-resource-group: "ads"
operationId: listAdCatalogs
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: List Meta product catalogs
description: "Lists the Meta product catalogs reachable from an ad account (owned + agency-shared catalogs of the ad account's business), for Advantage+ catalog ads (`goal: catalog_sales` on POST /v1/ads/create — e.g. vehicle inventory catalogs). Read-only; uses scopes customers already granted (no reconnect needed). Catalog contents (items, feeds) are managed in Meta Commerce Manager, not through this API."
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "A facebook, instagram, or metaads social account ID" }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
responses:
'200':
description: Catalogs
content:
application/json:
schema:
type: object
properties:
catalogs:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
vertical: { type: [string, "null"], description: "Catalog vertical (e.g. commerce, vehicles, hotels)" }
productCount: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/catalogs/{catalogId}/product-sets:
get:
x-resource-group: "ads"
operationId: listAdCatalogProductSets
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: List a catalog's product sets
description: "Lists a Meta product catalog's product sets — the unit a catalog ad promotes. Pass the chosen set as `promotedObject.productSetId` on POST /v1/ads/create with `goal: catalog_sales`."
security:
- bearerAuth: []
parameters:
- { name: catalogId, in: path, required: true, schema: { type: string }, description: "Meta product catalog ID (from GET /v1/ads/catalogs)" }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "A facebook, instagram, or metaads social account ID" }
responses:
'200':
description: Product sets
content:
application/json:
schema:
type: object
properties:
productSets:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
productCount: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/audiences:
get:
x-resource-group: "ads"
operationId: listAdAudiences
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List custom audiences
description: Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X (Twitter).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: Platform ad account ID }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, googleads, tiktok, tiktokads, pinterest, linkedin, linkedinads, twitter, xads] } }
- { name: type, in: query, required: false, schema: { type: string, enum: [customer_list, company_list, engagement, meta_engagement, website, website_retargeting, lookalike, saved_targeting] }, description: "Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences; the other types return uploaded/derived audiences." }
responses:
'200':
description: Audiences
content:
application/json:
schema:
type: object
properties:
audiences:
type: array
items:
type: object
properties:
id: { type: [string, "null"] }
accountId: { type: string, description: "Social account the audience was created against. Returned for saved_targeting items." }
platformAudienceId: { type: string }
name: { type: string }
description: { type: string }
type: { type: string, enum: [customer_list, company_list, engagement, meta_engagement, website, website_retargeting, lookalike, saved_targeting] }
spec:
anyOf:
- $ref: '#/components/schemas/TargetingSpec'
- type: "null"
description: "Present (and the only meaningful payload) when `type` is `saved_targeting`. Null for uploaded/derived audience types."
platform: { type: string }
size: { type: integer }
status: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
post:
x-resource-group: "ads"
operationId: createAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Create custom audience
description: |
Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest;
`website` and `lookalike` are Meta-only; `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: "Social account ID on the target ad platform." }
name: { type: string, maxLength: 255 }
description: { type: string }
spec:
allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
description: "The targeting spec to store."
responses:
'201':
description: Audience created
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
message: { type: string }
'400':
description: Missing required fields
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/audiences/{audienceId}:
get:
x-resource-group: "ads"
operationId: getAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Get audience details
description: Returns the local audience record and fresh data from Meta (if available).
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Audience details
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
metaData: { type: [object, "null"], description: Fresh data from Meta API }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "ads"
operationId: updateAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Update an audience
description: |
Update an audience. `saved_targeting` audiences accept `name`, `description`, and `spec`
(full replacement, no merge, Zernio-only, no platform call). Platform audiences
(uploaded/website/lookalike) accept `name` and `description` only, updated on the
platform first and then mirrored locally; their rules are immutable, so `spec` returns
400 for them. Platform audience updates are Meta-only for now (other platforms return
501). Ads already created from a saved_targeting audience are unaffected, they snapshot
the targeting at creation.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
description: At least one field is required.
properties:
name: { type: string, maxLength: 255 }
description: { type: string }
spec:
allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
description: Full replacement for the stored targeting spec.
responses:
'200':
description: Audience updated
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
message: { type: string }
'400':
description: 'Invalid body (no fields provided, malformed spec, or spec on a platform audience)'
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: The audience has no platform counterpart to update
'501':
description: Platform audience updates are only supported on Meta
delete:
x-resource-group: "ads"
operationId: deleteAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Delete custom audience
description: Deletes the audience from both the platform and the local database. `saved_targeting` audiences exist only on Zernio, so only the local record is removed.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Audience deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/audiences/{audienceId}/users:
post:
x-resource-group: "ads"
operationId: addUsersToAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Add users to audience
description: |
Upload user data to a customer_list audience. Data is SHA256-hashed server-side before sending to the platform.
Email is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest,
the first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. Max 10,000 users per request.
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 } }
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. LinkedIn does not document how quickly companies dropped from the list stop
being targeted, so treat removals as eventual rather than immediate.
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 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 } }
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:
'200':
description: Match-quality rows, one per event name.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, example: metaads }
rows:
type: array
items:
type: object
properties:
eventName: { type: string }
compositeScore: { type: number, description: "Composite EMQ score, 0-10." }
matchKeys:
type: array
items:
type: object
properties:
identifier: { type: string }
coveragePercentage: { type: number }
eventCoveragePercentage: { type: number, description: "Pixel↔CAPI coverage rate for this event." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'405': { description: "Platform does not expose Event Match Quality (non-Meta)." }
/v1/ads/conversions:
post:
x-resource-group: "ads"
operationId: sendConversions
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Send conversion events
description: |
Relay one or more conversion events to the target ad platform's native Conversions API.
Platform is inferred from the provided `accountId`. Requires the Ads add-on.
Supported platforms:
- Meta (`metaads`) via Graph API
- Google Ads (`googleads`) via Data Manager API `ingestEvents`
- LinkedIn (`linkedinads`) via `/rest/conversionEvents`
- TikTok (`tiktokads`) via the Offline Events API `/offline/batch/` — OFFLINE conversions only
- OpenAI Ads (`openaiads`) via its Conversions API (a separate host, `bzr.openai.com`)
`destinationId` semantics differ per platform:
- Meta: pixel (dataset) ID, e.g. `123456789012345`
- Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`
- LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012`
- TikTok: Offline Event Set ID, e.g. `7057103914977558530`
- OpenAI Ads: pixel wire id (numeric `pixel_id`, distinct from the internal pixel id), as returned by `GET /v1/accounts/{accountId}/conversion-destinations`
TikTok notes: this path sends OFFLINE conversions (in-store / CRM / call-center), not web-pixel
events. Each event must carry an email or phone (TikTok requires at least one). The connected
TikTok ads account must have granted the Offline Events permission; older grants must reconnect.
OpenAI Ads notes: requires a tracking tag (pixel) to already exist on the account — returns 422
with code `TRACKING_TAG_REQUIRED` if `POST /v1/accounts/{accountId}/tracking-tags` hasn't been
called yet.
Callers can list valid destinations via `GET /v1/accounts/{accountId}/conversion-destinations`.
All PII (email, phone, names, external IDs) is hashed with SHA-256 server-side per each
platform's normalization spec, including Google's Gmail-specific dot/plus-suffix stripping.
Send plaintext. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec;
only emails and phones are hashed.
For LinkedIn, the connected account must have been authorized after the Conversions API
rollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect.
Batching is handled automatically. Meta caps at 1000 events per request and rejects the
entire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is
also all-or-nothing per chunk. OpenAI Ads caps at 1000 per request; larger submissions are
split into 1000-event chunks, each all-or-nothing (a malformed event fails every event in
that chunk, not the whole request).
Dedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against
browser-side pixel/Insight Tag events; Google maps it to `transactionId`.
Per-platform `eventName` semantics:
- Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted.
- Google: ignored. The conversion action's category determines the event type. Send the standard name closest to your action for documentation, but the platform will not branch on it.
- LinkedIn: ignored. The conversion rule's `type` (LEAD, PURCHASE, etc.) is locked to the destination at rule-creation time. Send the standard name for documentation; LinkedIn does not branch on it.
- OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; any other standard name or custom string is sent as `type: custom` with the name preserved.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, destinationId, events]
properties:
accountId:
type: string
description: SocialAccount ID (metaads, googleads, linkedinads, tiktokads, or openaiads).
destinationId:
type: string
description: |
Platform destination identifier. For Meta, the pixel/dataset
ID. For Google, the conversion action resource name. For
LinkedIn, the conversion rule ID or full
`urn:lla:llaPartnerConversion:{id}` URN. For OpenAI Ads, the
pixel wire id.
events:
type: array
minItems: 1
items: { $ref: '#/components/schemas/ConversionEvent' }
testCode:
type: string
description: Meta `test_event_code` passthrough. Ignored by Google, LinkedIn, and OpenAI Ads.
consent:
type: object
description: |
Batch-level user consent. Required by Google for EEA/UK
events under the Feb 2026 restrictions. On Meta, any
DENIED flag enables Limited Data Use on every event in
the batch (data_processing_options ["LDU"] with
geolocation, country 0 / state 0); GRANTED or absent
consent sends events with Meta's default processing.
Ignored by LinkedIn.
properties:
adUserData: { type: string, enum: [GRANTED, DENIED] }
adPersonalization: { type: string, enum: [GRANTED, DENIED] }
responses:
'200':
description: |
Events processed. Inspect `eventsFailed` and `failures[]` to detect
partial failure. For Meta, a batch is all-or-nothing (either every
event in a chunk succeeds, or every event in the chunk is listed
in failures). For Google, the API returns success/failure at the
request level only. For OpenAI Ads, each 1000-event chunk is
all-or-nothing, same as Meta.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
eventsReceived: { type: integer, description: Events accepted by the platform. }
eventsFailed: { type: integer, description: Events rejected (see failures). }
failures:
type: array
items:
type: object
properties:
eventIndex: { type: integer, description: Index into the submitted events array. }
eventId: { type: string, description: Echoes back the eventId of the failed event. }
message: { type: string }
code: { oneOf: [{ type: string }, { type: integer }] }
traceId:
type: string
description: |
Platform trace ID for debugging. fbtrace_id for Meta,
requestId for Google. Absent for LinkedIn (LinkedIn's
conversionEvents endpoint does not surface a trace ID)
and OpenAI Ads (no trace ID surfaced).
'400':
description: Invalid body (missing accountId/destinationId/events, malformed event shape).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
'404':
description: Account not found or not accessible.
'422':
description: 'OpenAI Ads only: no tracking tag (pixel) exists yet for this account. Code `TRACKING_TAG_REQUIRED` — create one via `POST /v1/accounts/{accountId}/tracking-tags` first.'
'429':
description: |
LinkedIn token-level rate limit hit (600 requests/min, 300k/day
per token). Retry with backoff. Meta and Google have their own
rate-limit semantics surfaced via platform-specific 4xx responses.
/v1/ads/conversions/adjustments:
post:
x-resource-group: "ads"
operationId: adjustConversions
tags: [Conversions]
x-platforms: ["meta"]
summary: Adjust uploaded conversions
description: |
Adjust conversions that were previously uploaded via `POST /v1/ads/conversions` —
retract them, restate their value, or enhance them with first-party data. Requires
the Ads add-on.
**Google Ads only.** Google handles adjustments through the classic Google Ads API
(`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for
sending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this
endpoint returns `405` for those platforms.
Adjustment types:
- `RETRACTION` — remove the conversion entirely (refund, chargeback, cancelled order, churn).
- `RESTATEMENT` — change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta).
- `ENHANCEMENT` — attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact).
Identifying the original conversion (per adjustment):
- `orderId` — the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`.
- or `gclid` + `conversionTime` — the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`.
`destinationId` is the conversion action resource name, e.g.
`customers/1234567890/conversionActions/987654321` (same value you send to
`POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side
(Gmail-specific normalization included). Send plaintext.
Times are unix seconds; we convert to Google's required
`yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial
failure is supported (inspect `adjustmentsFailed` / `failures[]`).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, destinationId, adjustments]
properties:
accountId:
type: string
description: SocialAccount ID. Must be a `googleads` account.
destinationId:
type: string
description: Conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`.
adjustments:
type: array
minItems: 1
maxItems: 2000
items:
type: object
required: [adjustmentType, adjustmentTime]
properties:
adjustmentType:
type: string
enum: [RETRACTION, RESTATEMENT, ENHANCEMENT]
adjustmentTime:
type: number
description: When the adjustment occurred, unix seconds.
orderId:
type: string
description: Transaction ID of the original conversion (the `eventId` you sent). Recommended; required for ENHANCEMENT.
gclid:
type: string
description: Alternative key — the original click ID. Pair with `conversionTime`. Not valid for ENHANCEMENT.
conversionTime:
type: number
description: The original conversion's time, unix seconds. Required when identifying by `gclid`.
restatementValue:
type: number
description: RESTATEMENT only — the corrected TOTAL conversion value.
currency:
type: string
description: RESTATEMENT only — ISO 4217 currency for `restatementValue`.
user:
type: object
description: ENHANCEMENT only — first-party identifiers (hashed server-side). At least one of email/phone required.
properties:
email: { type: string }
phone: { type: string }
userAgent:
type: string
description: ENHANCEMENT only — the original conversion's user agent (improves match quality).
responses:
'200':
description: |
Adjustments processed. Inspect `adjustmentsFailed` and `failures[]` for
partial failure (Google reports per-row errors via partial failure).
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [googleads] }
adjustmentsReceived: { type: integer, description: Adjustments accepted by Google. }
adjustmentsFailed: { type: integer, description: Adjustments rejected (see failures). }
failures:
type: array
items:
type: object
properties:
adjustmentIndex: { type: integer, description: Index into the submitted adjustments array. }
message: { type: string }
code: { oneOf: [{ type: string }, { type: integer }] }
traceId: { type: string }
'400':
description: Invalid body, or a malformed adjustment (missing key, missing restatementValue for RESTATEMENT, missing identifiers for ENHANCEMENT).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required (Ads add-on on legacy plans, included on usage-based plans).
'404':
description: Account not found or not accessible.
'405':
description: Conversion adjustments are only available for Google Ads (the account's platform is not `googleads`).
/v1/accounts/{accountId}/conversion-destinations:
get:
x-resource-group: "ads"
operationId: listConversionDestinations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: List conversion destinations
description: |
Returns the list of pixels (Meta), conversion actions (Google),
conversion rules (LinkedIn), or pixels (OpenAI Ads) accessible to the
connected ads account. Use the returned `id` as `destinationId` when
posting to `POST /v1/ads/conversions`.
For Google and LinkedIn, each destination's `type` reflects the
conversion type (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is
locked to the destination. For Meta and OpenAI Ads, `type` is absent:
pixels accept any event name per request.
For LinkedIn, destinations are returned across every sponsored ad
account the connected token can access; the `adAccountId` field on
each destination identifies the parent ad account and is required for
subsequent CRUD calls (update, delete, associations, metrics).
security:
- bearerAuth: []
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: SocialAccount ID (metaads, googleads, linkedinads, tiktokads, or openaiads).
responses:
'200':
description: Destinations listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
destinations:
type: array
items:
type: object
properties:
id:
type: string
description: |
Destination identifier. Meta: pixel ID. Google:
conversion action resource name. LinkedIn:
numeric conversion rule ID. OpenAI Ads: pixel wire
id.
name: { type: string }
type:
type: string
description: |
Present when the platform locks event type to the
destination (Google conversion actions, LinkedIn
conversion rules).
status: { type: string, enum: [active, inactive] }
adAccountId:
type: string
description: |
Set by adapters whose destinations are scoped to a
specific ad account (LinkedIn). Pass back on
subsequent CRUD calls.
'400':
description: Account's platform is not supported by the Conversions API.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
'404':
description: Account not found or not accessible.
'429':
description: LinkedIn rate limit hit. Retry with backoff.
post:
x-resource-group: "ads"
operationId: createConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Create a conversion destination
description: |
Create a new conversion destination on the platform. Supported for
LinkedIn (conversion rule) and Google Ads (conversion action). Meta
and OpenAI Ads pixels are created via their own tracking-tags flow
instead (`POST /v1/accounts/{accountId}/tracking-tags`); this endpoint
returns 405 for both.
**LinkedIn:** creation is NOT idempotent. A retry creates a second
destination. Deduplicate before retrying.
**Google Ads:** calling with a name that already exists reuses the
existing conversion action transparently (the response is identical to
a fresh create). Calling with the same name but a different category
returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently
returning the mismatched action.
**LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API`
and (by default) auto-associated with all of the ad account's campaigns
via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE`
to opt out and manage associations explicitly via the associations
endpoints below.
365-day attribution windows are only valid for `SUBMIT_APPLICATION`,
`PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types;
the API rejects other combinations locally.
**Google Ads:** the conversion action is created with
`type=UPLOAD_CLICKS` (required for API-uploaded offline conversions,
immutable after creation). The `type` field carries the Google
`ConversionActionCategory` enum value, e.g. `PURCHASE`,
`SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`.
Unified standard event names (e.g. `Purchase`, `Subscribe`,
`CompleteRegistration`, `Lead`, `Schedule`) are resolved to their
Google category equivalents automatically. The action defaults to
secondary (non-primary) to avoid immediately steering Smart Bidding;
pass `primaryForGoal: true` to opt in.
security:
- bearerAuth: []
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: SocialAccount ID (linkedinads or googleads).
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, name, type]
properties:
adAccountId:
type: string
description: |
Ad account ID. For LinkedIn: numeric (e.g. "5123456") or
full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric
customer ID (e.g. "1234567890") or `customers/{id}` form.
name: { type: string, maxLength: 255 }
type:
type: string
description: |
Conversion type. For LinkedIn: a unified standard event name
(e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule
type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google:
a unified standard event name (Purchase, Subscribe,
CompleteRegistration, Lead, Schedule) or a Google
ConversionActionCategory enum value directly (e.g.
"PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD",
"BOOK_APPOINTMENT"). Unknown values pass through to the
platform.
attributionType:
type: string
enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
description: LinkedIn only.
postClickAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
LinkedIn only. Default 30. 365 only allowed for LEAD,
PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION
rule types; the API rejects other combinations locally.
viewThroughAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
LinkedIn only. Default 7. Same 365-day-window type
restriction applies as `postClickAttributionWindowSize`.
valueType:
type: string
enum: [DYNAMIC, FIXED, NO_VALUE]
description: |
LinkedIn only. DYNAMIC (default) uses the per-event `value`
from `sendConversions`. FIXED uses the rule's `value` field.
NO_VALUE drops monetary value entirely.
value:
type: object
required: [currencyCode, amount]
description: |
LinkedIn only. Static conversion value. Used when
`valueType=FIXED`. The currency should match the ad
account's currency.
properties:
currencyCode: { type: string, minLength: 3, maxLength: 3, description: ISO 4217 (e.g. "USD"). }
amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
autoAssociationType:
type: string
enum: [ALL_CAMPAIGNS, OBJECTIVE_BASED, NONE]
default: ALL_CAMPAIGNS
description: |
LinkedIn only. Controls campaign association at rule-creation
time:
- ALL_CAMPAIGNS: associate the rule with every active,
paused, and draft campaign in the ad account
- OBJECTIVE_BASED: associate only campaigns whose
objective matches the rule's type
- NONE: don't auto-associate. Manage associations via
the `/associations` endpoints below.
Note: auto-association runs once at create time; new
campaigns added after the rule still need explicit
association.
countingType:
type: string
enum: [MANY_PER_CLICK, ONE_PER_CLICK]
description: |
Google Ads only. Whether to count multiple conversions from
the same click (MANY_PER_CLICK) or at most one
(ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted.
primaryForGoal:
type: boolean
description: |
Google Ads only. When true, the conversion action is marked
as primary and immediately influences Smart Bidding. Defaults
to false (secondary, record-only) to avoid unintentionally
steering the customer's campaigns on creation.
responses:
'201':
description: Destination created
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads, googleads] }
destination: { $ref: '#/components/schemas/ConversionDestination' }
'400':
description: Invalid body or platform validation failure.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
or the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required).
'404':
description: Account not found or not accessible.
'405':
description: Platform does not support destination creation.
'409':
description: |
Google Ads only. A conversion action with the given name already
exists but has a different category. Use a different name or use
the existing destination. Error code: `IDEMPOTENCY_CONFLICT`.
'429':
description: Rate limit hit. Retry with backoff.
/v1/accounts/{accountId}/conversion-destinations/{destinationId}:
get:
x-resource-group: "ads"
operationId: getConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Get a conversion destination
description: |
LinkedIn-only today. Returns the full destination record for one
conversion rule. The `adAccountId` query parameter is required because
LinkedIn rules are scoped to a sponsored ad account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
required: true
schema: { type: string }
description: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN.
responses:
'200':
description: Destination fetched
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
destination: { $ref: '#/components/schemas/ConversionDestination' }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support fetching a single destination. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
patch:
x-resource-group: "ads"
operationId: updateConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Update a conversion destination
description: |
Partial-update a conversion rule. LinkedIn-only today. Whitelisted
fields: `name`, `enabled`, attribution windows, `valueType`, `value`,
`attributionType`. The rule's `type` and parent ad account are
intentionally not exposed for update — recreate the rule if those
need to change.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId]
description: |
At least one mutable field beyond `adAccountId` is required;
the route returns 400 if no patch fields are provided.
properties:
adAccountId: { type: string }
name: { type: string, maxLength: 255 }
enabled:
type: boolean
description: |
Setting `false` is equivalent to calling DELETE — the
rule will appear as `inactive` afterwards.
attributionType:
type: string
enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
postClickAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
viewThroughAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
valueType:
type: string
enum: [DYNAMIC, FIXED, NO_VALUE]
value:
type: object
description: Used when `valueType=FIXED`.
properties:
currencyCode: { type: string, description: ISO 4217. }
amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
responses:
'200':
description: Destination updated (re-fetched canonical state)
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
destination: { $ref: '#/components/schemas/ConversionDestination' }
'400': { description: Invalid body or LinkedIn validation failure. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support updating destinations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
delete:
x-resource-group: "ads"
operationId: deleteConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Delete a conversion destination
description: |
LinkedIn-only today. LinkedIn does not expose hard-delete on conversion
rules — what their UI calls "delete" is the same `enabled: false` flip
we apply here. The rule remains fetchable via GET with
`status: 'inactive'`; the unified discovery endpoint hides it by
default.
`adAccountId` may be passed as a query parameter (recommended) or as
a JSON body field for clients that can send DELETE bodies.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
schema: { type: string }
description: Required as query OR in JSON body.
responses:
'204': { description: Soft-deleted. }
'400': { description: 'adAccountId missing, or accountId is not a valid id.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support deleting destinations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
/v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations:
get:
x-resource-group: "ads"
operationId: listConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: List associated campaigns
description: |
LinkedIn-only today. Returns the campaigns currently associated with
this conversion rule. Note that auto-association on rule creation
runs once at create time; campaigns created after the rule still need
explicit association.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
required: true
schema: { type: string }
responses:
'200':
description: Associations listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
associations:
type: array
items:
type: object
properties:
campaignId: { type: string }
conversionId: { type: string }
associatedAt: { type: integer, description: Epoch ms. }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support associations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
post:
x-resource-group: "ads"
operationId: addConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Associate campaigns
description: |
Associate one or more campaigns with this conversion rule. Returns a
per-campaign success/failure result so callers can retry only the
rows that failed (e.g. wrong campaign type for the rule's objective).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, campaignIds]
properties:
adAccountId: { type: string }
campaignIds:
type: array
minItems: 1
maxItems: 100
items:
type: string
description: Numeric campaign ID or full `urn:li:sponsoredCampaign:{id}` URN.
responses:
'200':
description: |
Per-campaign batch result. Status is 200 even when some rows
failed — inspect `failed[]` for details. Inputs that fail local
URN validation are bucketed into `failed` without ever hitting
LinkedIn.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
succeeded:
type: array
description: Numeric campaign IDs that were successfully associated.
items: { type: string }
failed:
type: array
items:
type: object
properties:
campaignId: { type: string }
reason: { type: string }
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support associations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
delete:
x-resource-group: "ads"
operationId: removeConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Remove associated campaigns
description: |
Remove one or more campaign associations from this conversion rule.
Pass `adAccountId` and `campaignIds` as query parameters
(`campaignIds` is comma-separated). The route also accepts a JSON
body with the same fields for clients that prefer DELETE-with-body,
but the documented surface is query-only because some SDK code
generators (e.g. Python) collapse query + body parameters with the
same name into a single kwarg.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
- { name: campaignIds, in: query, required: true, schema: { type: string }, description: 'Comma-separated list of campaign IDs.' }
responses:
'200':
description: |
Per-campaign batch result. Status is 200 even when some rows
failed — inspect `failed[]` for details.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
succeeded:
type: array
description: Numeric campaign IDs that were successfully removed.
items: { type: string }
failed:
type: array
items:
type: object
properties:
campaignId: { type: string }
reason: { type: string }
'400':
description: |
Validation error: missing `adAccountId` or `campaignIds`,
campaignIds exceeds 100 entries per request, or `accountId` is not
a valid id.
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support associations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
/v1/accounts/{accountId}/conversion-destinations/{destinationId}/metrics:
get:
x-resource-group: "ads"
operationId: getConversionMetrics
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Get attribution metrics
description: |
LinkedIn-only today. Returns conversion-attribution metrics
(`externalWebsiteConversions`, `externalWebsitePostClickConversions`,
`externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`,
`qualifiedLeads`, `costInLocalCurrency`) bucketed by date.
Date-range constraints (passed through from LinkedIn):
- `granularity=DAILY` is retained for ~6 months only
- `granularity=ALL` with a range > 6 months auto-rounds to month boundaries
- `granularity=MONTHLY`/`YEARLY` retains 24 months
Throttle: LinkedIn caps adAnalytics at 45M metric values per 5-minute
window across the calling token. Single-rule queries are well within
that limit; surfaces as 429 if hit.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
- { name: startDate, in: query, required: true, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
- { name: endDate, in: query, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
- name: granularity
in: query
schema:
type: string
enum: [ALL, DAILY, MONTHLY, YEARLY]
default: DAILY
responses:
'200':
description: Metrics rows
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
granularity: { type: string, enum: [ALL, DAILY, MONTHLY, YEARLY] }
rows:
type: array
items:
type: object
properties:
start: { type: string, description: 'YYYY-MM-DD' }
end: { type: string, description: 'YYYY-MM-DD (inclusive)' }
metrics:
type: object
additionalProperties: { oneOf: [{ type: number }, { type: string }] }
'400': { description: Validation error or invalid date range. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { description: Account or destination not found. }
'405': { description: Platform does not support metrics readback. }
'429': { description: LinkedIn analytics rate limit hit. }
/v1/whatsapp/conversions:
get:
x-resource-group: "ads"
operationId: listWhatsAppConversions
tags: [WhatsApp]
summary: List conversion events
description: |
Returns the most recent conversion events sent through
`POST /v1/whatsapp/conversions` for the given WhatsApp account.
Sourced from delivery logs (Axiom `late` dataset), so the visible
window is bounded by log retention (about 30 days). Useful for
rendering a "recent activity" panel on the conversions setup tab
without standing up a parallel persistence layer.
Per-event payload mirrors the structured log we write on every
successful send: `eventName`, `conversationId`, `eventsReceived`,
`eventsFailed`, `traceId`, `durationMs`, and the wall-clock
`timestamp`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- name: limit
in: query
required: false
description: Max events to return (1-200, default 50).
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
'200':
description: Recent conversion events
content:
application/json:
schema:
type: object
properties:
events:
type: array
items:
type: object
properties:
timestamp:
type: string
format: date-time
description: When the event was sent to Meta.
eventName:
type: string
description: One of LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent.
conversationId:
type: [string, "null"]
eventsReceived:
type: [integer, "null"]
description: Number of events Meta accepted on this send (usually 1).
eventsFailed:
type: [integer, "null"]
description: Number of events Meta rejected (usually 0).
traceId:
type: [string, "null"]
description: Meta fbtrace_id for cross-referencing in Events Manager.
durationMs:
type: [integer, "null"]
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "ads"
operationId: sendWhatsAppConversion
tags: [WhatsApp]
summary: Send WhatsApp conversion event
description: |
Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`,
`Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's
Conversions API with `action_source = business_messaging` and
`messaging_channel = whatsapp`. The endpoint looks up the originating
CTWA click ID (`ctwa_clid`) captured on the first inbound message of
the conversation and replays it on every event so Meta can attribute
the conversion back to the Click-to-WhatsApp ad that drove the chat.
Configuration prerequisite on the WhatsApp account metadata:
- `metaCapiDatasetId`: the Meta dataset ID linked to the WABA.
Provision one with `POST /v1/whatsapp/dataset`.
The WABA ID (already set automatically at connect time) is forwarded as
`user_data.whatsapp_business_account_id`, which is the per-channel
attribution identifier Meta requires for WhatsApp events. No Facebook
Page ID is needed (that field is the Messenger-branch identifier).
Identify the conversation by either `conversationId` (preferred) or
`phoneE164` (digits only, no `+`). At least one is required. If the
conversation has no captured `ctwa_clid`, the request returns 422
because there is nothing to attribute.
Token and dataset coupling: the WhatsApp account's accessToken must
have access to the configured `metaCapiDatasetId`. By default a WABA's
system-user token is scoped to the WABA's own Business Manager and
cannot post to a pixel owned by a different Business; Meta returns
code 100 in that case. Either share the dataset with the WhatsApp
app's Business in BM, or use a dataset already in the same Business
as the WABA.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, eventName, eventId]
description: |
In addition to the `required` list, at least one of
`conversationId` or `phoneE164` must be supplied (used to
resolve the originating CTWA conversation). The route enforces
this at the Zod boundary; OpenAPI's `required` cannot express
OR-required cleanly.
properties:
accountId:
type: string
minLength: 1
description: WhatsApp SocialAccount ID.
eventName:
type: string
enum: [LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent]
description: |
Live-verified allowlist of event names accepted by Meta's
CAPI for Business Messaging (Graph API v25.0). Other
standard pixel events including `Lead`,
`CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`,
`StartTrial`, `AddPaymentInfo`, `Search`, and
`SubmitApplication` are rejected with subcode 2804066
("Messaging Event Invalid Event Type") on
`action_source = business_messaging` events. Custom event
names are also rejected.
Use `LeadSubmitted` (NOT `Lead`) for lead-style conversions.
eventTime:
type: number
description: |
Unix seconds. Defaults to the time of the request when
omitted. Meta's attribution window is 7 days from click;
events older than that lose attribution.
eventId:
type: string
minLength: 1
description: |
Stable dedup key. Reuse to suppress duplicate events
(Meta dedupes against pixel events with the same id).
conversationId:
type: string
minLength: 1
description: |
Zernio Conversation `_id` (preferred lookup). The
conversation must have a captured `ctwa_clid` in metadata
(set automatically by the WhatsApp webhook on the first
inbound message after a CTWA ad click).
phoneE164:
type: string
minLength: 1
description: |
Contact phone number, digits only with no '+'. When used
in lieu of `conversationId`, the handler resolves to the
most recent CTWA-attributed conversation for this phone
on the supplied account.
value:
type: number
description: Conversion value (e.g. order total).
currency:
type: string
minLength: 3
maxLength: 3
description: ISO 4217 currency code (e.g. `USD`).
contentIds:
type: array
items: { type: string }
description: Optional product / content identifiers.
email:
type: string
format: email
description: User email. Normalized + SHA-256 hashed before sending to Meta.
externalId:
type: string
description: |
Stable customer identifier. Lowercased + SHA-256 hashed
before sending to Meta.
testCode:
type: string
description: |
Meta `test_event_code` passthrough. Routes the event to
the Test Events tab in Events Manager instead of the
production dataset, useful for development.
responses:
'200':
description: |
Event submitted to Meta. Inspect `eventsFailed` and `failures[]`
to detect partial failures. A 200 does not mean Meta accepted the
event; the status reflects "request reached Meta" only.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
eventsReceived:
type: integer
description: Events accepted by Meta.
eventsFailed:
type: integer
description: Events rejected by Meta (see failures).
failures:
type: array
description: |
Per-event failure detail. Empty when all events were
accepted.
items:
type: object
properties:
eventIndex:
type: integer
description: Index into the submitted events array.
eventId:
type: string
description: Echoes back the eventId of the failed event.
message: { type: string }
code:
oneOf:
- { type: string }
- { type: integer }
traceId:
type: string
description: |
Meta `fbtrace_id` for debugging. Surface in support
tickets.
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Conversation not found. }
'422':
description: |
Configuration missing (no `metaCapiDatasetId` on the account, set
it via POST /v1/whatsapp/dataset) OR the resolved conversation has
no captured `ctwa_clid`.
/v1/ads/messaging:
post:
x-resource-group: "ads"
operationId: createMessagingAd
tags: ["Messaging Ads"]
x-platforms: ["meta"]
summary: Create click-to-message ad (WhatsApp / Messenger / Instagram Direct)
description: |-
Creates a click-to-message ad; `destination` selects where the tapped ad opens a
conversation: WhatsApp, the Page's Messenger inbox or the linked Instagram account's Direct inbox.
The ad set is created with the matching destination_type and
CONVERSATIONS optimization; the campaign objective defaults to OUTCOME_ENGAGEMENT.
Supports single-creative and multi-creative shapes. Supersedes POST /v1/ads/ctwa
(deprecated, equivalent to `destination: whatsapp`).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/CtwaAdRequestBody'
- type: object
required: [destination]
properties:
destination:
type: string
enum: [whatsapp, messenger, instagram_direct]
description: Where the conversation opens when the ad is tapped.
responses:
'201':
description: Ad(s) created and submitted for review
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'422': { description: "No Facebook Page resolved for the account" }
'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:
'201':
description: Ad(s) created and submitted for review
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'422': { description: "No Facebook Page resolved for the account" }
'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 Click-to-WhatsApp ad (deprecated)
deprecated: true
description: |-
Deprecated: use POST /v1/ads/messaging with `destination: whatsapp`. This endpoint stays available for back-compat; no removal planned.
Creates one or more Click-to-WhatsApp (CTWA) ads on Meta under a single campaign and ad set. When tapped, each ad opens a WhatsApp conversation with the business attached to the supplied Facebook Page. The full hierarchy (campaign, ad set, creative(s), ad(s)) is created and activated in one call. The CTA is locked to WHATSAPP_MESSAGE and the destination is hard-coded to api.whatsapp.com/send; Meta resolves the actual WhatsApp number from the Page-to-WA pairing configured in Page settings or Business Manager.
Supports two mutually-exclusive shapes:
- **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`. Creates 1 campaign + 1 ad set + 1 ad.
- **Multi-creative**: supply a `creatives[]` array with N entries (each carrying its own headline, body, and image/video). Creates 1 campaign + 1 ad set + N ads sharing budget and targeting so Meta A/Bs the creatives inside a single auction instead of fragmenting budget across N parallel campaigns. Recommended when launching multiple creative variants for the same campaign.
**Attach shape.** Send `adSetId` (with either creative shape) to add the ads to an EXISTING messaging ad set instead of building a campaign, so the ad set keeps its learning phase — the way to refresh a CTWA creative without resetting delivery. The ad set then owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, `endDate`, `objective`, `countries`, `interests` and `audienceId` are rejected with a 400 alongside it rather than silently dropped. The target ad set's `destination_type` must match the ad's destination (a WhatsApp ad needs a `WHATSAPP` ad set), otherwise Meta would accept an ad that never delivers.
Prerequisites enforced by Meta (surfaced as platform_error on failure): the Facebook Page must be paired with a verified WhatsApp Business number, the WhatsApp Business Account must be business-verified, and the Meta access token must carry ads_management.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CtwaAdRequestBody'
responses:
'201':
description: |
CTWA ad(s) created and submitted to Meta for review. Response is a
tagged union discriminated by `adType`:
- `adType: "single"` → single-creative request: `{ adType, ad,
message }` where `ad` is the persisted Ad document.
- `adType: "multi"` → multi-creative request: `{ adType, ads,
platformCampaignId, platformAdSetId, message }` where `ads` is
the array of N persisted Ad documents all sharing the returned
campaign and ad set IDs.
Generated SDK clients can narrow on `adType` instead of sniffing
for field presence.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CtwaSingleResponse'
- $ref: '#/components/schemas/CtwaMultiResponse'
discriminator:
propertyName: adType
mapping:
single: '#/components/schemas/CtwaSingleResponse'
multi: '#/components/schemas/CtwaMultiResponse'
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: "Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans." }
'404': { description: SocialAccount not found. }
'422':
description: Page is not connected to a verified WhatsApp number.
'502':
description: |
Meta rejected the request (e.g. WABA business verification
missing). Inspect `platformError` for the upstream Meta payload.
/v1/accounts/{accountId}/custom-conversions:
get:
x-resource-group: "accounts"
operationId: listCustomConversions
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: List custom conversions
description: The ad account's Meta custom conversions, including archived ones (`isArchived`).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: "Meta ads SocialAccount id." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
responses:
'200':
description: Custom conversions
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { $ref: '#/components/schemas/CustomConversion' }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: "Ads access required, or the token lacks the ads permissions." }
post:
x-resource-group: "accounts"
operationId: createCustomConversion
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Create or reuse a 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: "Meta ad account id (act_<n>)." }
name: { type: string, maxLength: 255, description: "Also the reuse key, together with pixelId." }
pixelId: { type: string, description: "Meta pixel id (event_source_id). From GET /v1/accounts/{accountId}/tracking-tags." }
customEventType: { type: string, description: "Meta custom_event_type, e.g. LEAD, PURCHASE, OTHER." }
rule: { type: object, description: "Meta conversion rule, forwarded verbatim." }
responses:
'200':
description: An existing custom conversion was reused
content:
application/json:
schema: { $ref: '#/components/schemas/CustomConversionResult' }
'201':
description: Custom conversion created
content:
application/json:
schema: { $ref: '#/components/schemas/CustomConversionResult' }
'400': { description: "Invalid input, or Meta rejected the conversion (bad rule, per-account cap reached)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: "Ads access required, or the token lacks the ads permissions." }
/v1/accounts/{accountId}/tracking-tags:
get:
x-resource-group: "ads"
operationId: listTrackingTags
tags: ["Tracking Tags"]
x-platforms: ["meta"]
summary: List tracking tags
description: |
Returns the tracking tags (Meta Pixels, or OpenAI Ads pixels) the
connected ads account can see. Pass `?adAccountId=act_...` (Meta only)
to scope the list to a single ad account; omit it to list every pixel
reachable by the token (the name is then suffixed with the ad account
it was discovered on, for disambiguation). The list view omits `code`
— call `getTrackingTag` for the install snippet and full detail (Meta
only; OpenAI Ads has no get-by-id endpoint).
Meta (platform `metaads`) and OpenAI Ads (platform `openaiads`); other
platforms return 405. The `accountId` must be the ads SocialAccount
created by the Ads add-on connect flow (Meta) or the OpenAI Ads
connect flow, not a Facebook/Instagram posting account. Get your Meta
`act_...` ids from `GET /v1/ads/accounts`; `adAccountId` is ignored for
OpenAI Ads (one API key maps to exactly one ad account).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
- { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Optional, Meta only. Scope to one ad account, e.g. `act_123456789`. Ignored for OpenAI Ads.' }
responses:
'200':
description: Tracking tags listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, openaiads] }
tags:
type: array
items: { $ref: '#/components/schemas/TrackingTag' }
'400': { description: 'Account platform not supported, or invalid `adAccountId`.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account not found or not accessible. }
'405': { description: Platform does not support listing tracking tags. }
'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`
— `name` is the only input). Returns the created tag including its
install `code`. The pixel is owned by the Business Manager that owns the
ad account; a pixel created on a personal (non-BM) ad account ends up
with `ownerBusinessId: null` and can't be shared with other ad accounts.
Creating a Meta pixel does NOT install it — install the returned `code`
snippet on the site, or send events server-side via
`POST /v1/ads/conversions`. The check `installed` is derived from
`lastFiredTime`.
OpenAI Ads: creates an OpenAI pixel AND provisions a Conversions API
key for it in the same call (`adAccountId` is required by this
endpoint but ignored — one API key maps to exactly one ad account, so
there's nothing to select). Returns 422 (`FEATURE_NOT_AVAILABLE`) if
the ad account isn't enabled for pixel management; contact your OpenAI
partner representative to enable it. There is no delete API for
OpenAI pixels. If the pixel is created but the Conversions API key
provisioning then fails, the pixel is left live on OpenAI (it cannot
be cleaned up) and the error message names the surviving pixel id and
warns against retrying, since a retry would create a second, orphaned
pixel.
NOT idempotent on either platform: each call creates a new pixel (and,
for OpenAI, a new Conversions API key). Do not retry blindly on
timeout. Meta (platform `metaads`) and OpenAI Ads (platform
`openaiads`); other platforms return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, name]
properties:
adAccountId: { type: string, description: 'Meta ad account id, e.g. `act_123456789`. Required by this endpoint but ignored for OpenAI Ads.' }
name: { type: string, minLength: 1, maxLength: 200 }
responses:
'201':
description: Tracking tag created
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, openaiads] }
tag: { $ref: '#/components/schemas/TrackingTag' }
'400': { description: 'Invalid body, invalid `adAccountId`, over the per-business pixel cap, or ad account not in a Business Manager.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account not found or not accessible. }
'405': { description: Platform does not support creating tracking tags. }
'422': { description: 'OpenAI Ads only: the ad account is not enabled for pixel management. Contact your OpenAI partner representative.' }
'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:
'200':
description: Tracking tag fetched
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
tag: { $ref: '#/components/schemas/TrackingTag' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support fetching a tracking tag. }
'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:
'200':
description: Tracking tag updated (re-fetched canonical state)
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
tag: { $ref: '#/components/schemas/TrackingTag' }
'400': { description: Invalid body (e.g. no fields supplied) or Meta validation failure. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support updating tracking tags. }
'502': { description: 'Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable.' }
/v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts:
get:
x-resource-group: "ads"
operationId: listTrackingTagSharedAccounts
tags: ["Tracking Tags"]
x-platforms: ["meta"]
summary: List accounts it is shared with
description: Meta only (platform `metaads`); other platforms return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
responses:
'200':
description: Shared ad accounts listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
sharedAccounts:
type: array
items: { $ref: '#/components/schemas/SharedAdAccount' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support shared accounts. }
'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:
'201':
description: Tracking tag shared with the ad account
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
ok: { type: boolean }
'400': { description: 'Invalid body / `adAccountId`, or Meta rejected the share (e.g. personal ad account).' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support shared accounts. }
'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:
'204': { description: Ad account unshared (no content). }
'400': { description: '`adAccountId` missing (neither query nor body), or Meta rejected the unshare.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support shared accounts. }
'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:
'200':
description: Stats fetched
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
stats:
type: object
properties:
aggregation: { type: string }
startTime: { type: integer }
endTime: { type: integer }
rows:
type: array
items: { type: object, additionalProperties: true }
'400': { description: Invalid query parameter. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { description: Account or tracking tag not found. }
'405': { description: Platform does not support tracking-tag stats. }
'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). }