openapi: 3.1.0
info:
title: Zernio API
version: "1.0.1"
description: |
API reference for Zernio. Authenticate with a Bearer API key.
Base URL: https://zernio.com/api
termsOfService: https://zernio.com/tos
contact:
name: Zernio Support
url: https://zernio.com
email: support@zernio.com
# RapidAPI extensions for Hub listing
x-logo:
url: https://zernio.com/icon.png?v=3
x-long-description: |
Zernio is the social media API that replaces 15 integrations. Schedule posts, retrieve analytics,
manage DMs, comments, and reviews across Twitter/X, Instagram, TikTok, LinkedIn, Facebook,
YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business, Snapchat, and Discord,
all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google, TikTok,
LinkedIn, Pinterest, and X from the same account.
Key features: Unified posting to 15 platforms, ads management on 6 ad networks (via /v1/ads), aggregated analytics, unified inbox (DMs, comments, reviews), webhooks, OAuth connect, queue scheduling, and white-label support for agencies managing unlimited accounts.
Supported posting platforms: Twitter/X, Instagram, WhatsApp, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business, Telegram, Snapchat, Discord. Supported ad platforms: Meta Ads, Google Ads, TikTok Ads, LinkedIn Ads, Pinterest Ads, X Ads.
x-category: Social
x-website: https://zernio.com
x-thumbnail: https://rapidapi-prod-apis.s3.amazonaws.com/b24d3df5-563c-4a50-9e1e-1ad3eb1fce69.png
x-version-lifecycle: ACTIVE
x-badges:
- name: "social media"
value: "social media"
- name: "scheduling"
value: "scheduling"
- name: "instagram"
value: "instagram"
- name: "tiktok"
value: "tiktok"
- name: "twitter"
value: "twitter"
- name: "linkedin"
value: "linkedin"
- name: "facebook"
value: "facebook"
- name: "youtube"
value: "youtube"
- name: "social media api"
value: "social media api"
- name: "posting"
value: "posting"
# RapidAPI Hub documentation tab (README)
x-documentation:
readme: |
# Zernio API
The social media API that replaces 14 integrations. Build social media features into your app in minutes, not months.
## Quick Start
**Base URL:** `https://zernio.com/api/v1`
**Authentication:** All requests require a Bearer API key in the `Authorization` header.
```bash
curl https://zernio.com/api/v1/user \
-H "Authorization: Bearer YOUR_API_KEY"
```
Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys).
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Profiles** | Containers that organize social accounts into brands or projects |
| **Accounts** | Connected social media accounts belonging to a profile |
| **Posts** | Content scheduled or published to one or more accounts |
| **Queue** | Recurring time slots for automatic post scheduling |
## Create a Post
```bash
curl -X POST https://zernio.com/api/v1/post \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"profileId": "your-profile-id",
"text": "Hello from Zernio API!",
"socialAccountIds": ["account-1", "account-2"],
"scheduledAt": "2025-01-15T10:00:00Z"
}'
```
This single call publishes or schedules the post to all selected accounts across any platform.
## Supported Platforms
| Platform | Post | Stories/Reels | Analytics | Inbox |
|----------|------|---------------|-----------|-------|
| Twitter/X | Yes | - | Yes | Yes |
| Instagram | Yes | Yes | Yes | Yes |
| Facebook | Yes | Stories | Yes | Yes |
| LinkedIn | Yes | - | Partial | - |
| TikTok | Yes | - | Yes | - |
| YouTube | Yes | Shorts | Yes | Yes |
| Pinterest | Yes | - | Yes | - |
| Reddit | Yes | - | - | Yes |
| Bluesky | Yes | - | - | Yes |
| Threads | Yes | - | Yes | Yes |
| Google Business | Yes | - | - | Yes |
| Telegram | Yes | - | - | - |
| Snapchat | Yes | - | - | - |
> **LinkedIn Analytics Note:** For personal LinkedIn accounts, analytics are only available for posts published through Zernio. This is a LinkedIn API limitation: the `memberCreatorPostAnalytics` endpoint only returns metrics for posts authored by the authenticated user. Company/organization page analytics are not affected and work for all posts.
> **Google Business Analytics Note:** Per-post analytics for Google Business Profile are deprecated by Google with no replacement. Location-level metrics (impressions, clicks, calls, directions, bookings) are available via the dedicated `/v1/analytics/googlebusiness/performance` endpoint.
## Rate Limits
| Plan | Requests/min | Posts/month |
|------|-------------|-------------|
| Free | 60 | 20 |
| Build | 120 | 120 |
| Accelerate | 600 | Unlimited |
| Unlimited | 1,200 | Unlimited |
All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.
## Webhooks
Receive real-time notifications for post status changes, account events, and incoming messages:
- `post.scheduled` - Post created and scheduled for publishing
- `post.published` - Post successfully published
- `post.failed` - Post failed on all platforms
- `post.partial` - Post published to some platforms, failed on others
- `post.cancelled` - Post publishing was cancelled
- `post.recycled` - Post recycled (cloned and re-scheduled)
- `account.connected` - Social account connected
- `account.disconnected` - Social account disconnected (token expired)
- `account.ads.initial_sync_completed` - Initial ads sync (discovery + 90-day backfill) completed for an ads-enabled account
- `message.received` - New DM received
- `message.sent` - DM sent via the API
- `message.edited` - A sender edited a message (Instagram, Messenger, Telegram)
- `message.deleted` - A sender deleted ("unsent") a message (Instagram; WhatsApp when the business deletes a sent message)
- `message.delivered` - An outgoing message was delivered (WhatsApp, Messenger)
- `message.read` - An outgoing message was read by the recipient (WhatsApp, Messenger, Instagram)
- `message.failed` - An outgoing message failed delivery (WhatsApp)
- `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)
- `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
- name: Users
- name: Usage
- name: Profiles
- name: Accounts
- name: Account Groups
- name: API Keys
- name: Invites
- name: Connect
- name: Media
- name: Reddit Search
- name: Facebook
- name: GMB Reviews
- name: GMB Food Menus
- name: GMB Location Details
- name: GMB Media
- name: GMB Attributes
- name: GMB Place Actions
- name: Discord
description: |
Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.
- name: LinkedIn Mentions
- name: Pinterest
- name: TikTok
- name: Queue
- name: Analytics
- 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: 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: Comment Automations
description: |
Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook so
commenters automatically receive a DM. Scope per post or account-wide (omit
`platformPostId` to match comments on every post on the account, with unlimited
automations stacked per account). Supports dedup, optional public comment reply, and
auto-creates contacts.
- name: Ads
description: |
Paid advertising management across Meta (Facebook/Instagram), Google, TikTok, LinkedIn, Pinterest, and X/Twitter.
Create, boost, pause/resume ads and campaigns, view metrics, and manage audiences.
Requires the Ads add-on.
- name: Ad Campaigns
description: |
Campaign-level operations. Campaigns are virtual aggregations of ads grouped by their platform campaign ID.
List campaigns with aggregate metrics, or pause/resume all ads in a campaign at once.
Requires the Ads add-on.
- name: Ad Audiences
description: |
Custom audience management for ad targeting. Create customer lists, website retargeting audiences,
and lookalike audiences. Upload user data (hashed server-side). Currently Meta-only for creation,
read-only for other platforms.
Requires the Ads add-on.
- name: Webhooks
description: |
Configure webhooks for real-time notifications. 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, comment.received, review.new, review.updated, webhook.test.
Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported.
- name: Webhook Events
description: |
Incoming webhook deliveries sent by Zernio to your configured endpoint URL.
- name: Logs
description: |
Publishing logs for transparency and debugging. Each log includes the platform API endpoint, HTTP status code, request/response bodies, duration, and retry attempts. Logs are automatically deleted after 7 days.
- name: WhatsApp
description: |
WhatsApp Business API. Template, business profile, and phone number endpoints.
All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
- name: WhatsApp 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: WhatsApp Phone Numbers
description: |
Manage WhatsApp phone numbers: purchase, verify, and release numbers for your WhatsApp Business account.
Requires a paid plan.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: API key authentication - use your Zernio API key as a Bearer token
connectToken:
type: apiKey
in: header
name: X-Connect-Token
description: |
Short-lived connect token for API users during OAuth flows.
Automatically generated when initiating OAuth without a browser session.
Valid for 15 minutes. Used to authenticate Facebook page selection API calls.
parameters:
PageParam:
name: page
in: query
description: Page number (1-based)
schema: { type: integer, minimum: 1, default: 1 }
LimitParam:
name: limit
in: query
description: Page size
schema: { type: integer, minimum: 1, maximum: 100, default: 10 }
responses:
Unauthorized:
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Unauthorized
NotFound:
description: Resource not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Not found
schemas:
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, nullable: true, 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 }
ErrorResponse:
type: object
properties:
error:
type: string
details:
type: object
additionalProperties: true
WhatsAppTemplateButton:
type: object
required: [type, text]
properties:
type:
type: string
enum: [quick_reply, url, phone_number, otp, flow, mpm, catalog]
text:
type: string
url:
type: string
format: uri
description: Required when type is URL
example:
type: array
items: { type: string }
description: Example values for URL suffix variables
phone_number:
type: string
description: Required when type is phone_number
otp_type:
type: string
enum: [copy_code, one_tap, zero_tap]
description: Required when type is otp
autofill_text:
type: string
package_name:
type: string
signature_hash:
type: string
flow_id:
type: string
flow_name:
type: string
flow_json:
type: string
flow_action:
type: string
navigate_screen:
type: string
WhatsAppTemplateComponent:
oneOf:
- $ref: '#/components/schemas/WhatsAppHeaderComponent'
- $ref: '#/components/schemas/WhatsAppBodyComponent'
- $ref: '#/components/schemas/WhatsAppFooterComponent'
- $ref: '#/components/schemas/WhatsAppButtonsComponent'
discriminator:
propertyName: type
mapping:
header: '#/components/schemas/WhatsAppHeaderComponent'
body: '#/components/schemas/WhatsAppBodyComponent'
footer: '#/components/schemas/WhatsAppFooterComponent'
buttons: '#/components/schemas/WhatsAppButtonsComponent'
WhatsAppHeaderComponent:
type: object
required: [type, format]
properties:
type:
type: string
enum: [header]
format:
type: string
enum: [text, image, video, gif, document, location]
text:
type: string
description: Header text (may include {{1}} variable). Used when format is TEXT.
example:
type: object
properties:
header_text:
type: array
items: { type: string }
description: Sample values for header text variables
header_handle:
type: array
minItems: 1
maxItems: 1
items:
type: string
format: uri
description: When the header format is a media type (image, video, gif, document), provide a public URL here. Zernio will download and upload it to WhatsApp on your behalf, replacing it with the internal file handle before creating the template.
WhatsAppBodyComponent:
type: object
required: [type, text]
properties:
type:
type: string
enum: [body]
text:
type: string
description: Body text with optional {{n}} variables
add_security_recommendation:
type: boolean
description: Add security recommendation text (authentication templates only)
example:
type: object
properties:
body_text:
type: array
items:
type: array
items: { type: string }
description: Sample values for body variables (array of arrays)
WhatsAppFooterComponent:
type: object
required: [type]
properties:
type:
type: string
enum: [footer]
text:
type: string
description: Static footer text
code_expiration_minutes:
type: integer
minimum: 1
description: OTP code expiry in minutes (authentication templates only)
WhatsAppButtonsComponent:
type: object
required: [type, buttons]
properties:
type:
type: string
enum: [buttons]
buttons:
type: array
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateButton'
FoodMenuLabel:
type: object
required: [displayName]
properties:
displayName: { type: string, description: Display name of the item/section/menu }
description: { type: string, description: Optional description }
languageCode: { type: string, description: "BCP-47 language code (e.g. en, es)" }
Money:
type: object
required: [currencyCode, units]
properties:
currencyCode: { type: string, description: "ISO 4217 currency code (e.g. USD, EUR)" }
units: { type: string, description: Whole units of the amount }
nanos: { type: integer, description: Nano units (10^-9) of the amount }
FoodMenuItemAttributes:
type: object
properties:
price: { $ref: '#/components/schemas/Money' }
spiciness: { type: string, description: "Spiciness level (e.g. MILD, MEDIUM, HOT)" }
allergen:
type: array
items: { type: string }
description: "Allergens (e.g. DAIRY, GLUTEN, SHELLFISH)"
dietaryRestriction:
type: array
items: { type: string }
description: "Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE)"
servesNumPeople: { type: integer, description: Number of people the item serves }
preparationMethods:
type: array
items: { type: string }
description: "Preparation methods (e.g. GRILLED, FRIED)"
mediaKeys:
type: array
items: { type: string }
description: Media references for item photos
FoodMenuItem:
type: object
required: [labels]
properties:
labels:
type: array
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
options:
type: array
items:
type: object
properties:
labels:
type: array
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
description: Item variants/options (e.g. sizes, preparations)
FoodMenuSection:
type: object
required: [labels]
properties:
labels:
type: array
items: { $ref: '#/components/schemas/FoodMenuLabel' }
items:
type: array
items: { $ref: '#/components/schemas/FoodMenuItem' }
FoodMenu:
type: object
required: [labels]
properties:
labels:
type: array
items: { $ref: '#/components/schemas/FoodMenuLabel' }
sections:
type: array
items: { $ref: '#/components/schemas/FoodMenuSection' }
cuisines:
type: array
items: { type: string }
description: "Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE)"
sourceUrl:
type: string
description: URL of the original menu source
YouTubeDailyViewsResponse:
type: object
properties:
success:
type: boolean
example: true
videoId:
type: string
description: The YouTube video ID
dateRange:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
totalViews:
type: integer
description: Sum of views across all days in the range
dailyViews:
type: array
items:
type: object
properties:
date:
type: string
format: date
views:
type: integer
estimatedMinutesWatched:
type: number
averageViewDuration:
type: number
description: Average view duration in seconds
subscribersGained:
type: integer
subscribersLost:
type: integer
likes:
type: integer
comments:
type: integer
shares:
type: integer
lastSyncedAt:
type: string
format: date-time
nullable: true
description: When the data was last synced from YouTube
scopeStatus:
type: object
properties:
hasAnalyticsScope:
type: boolean
YouTubeScopeMissingResponse:
type: object
properties:
success:
type: boolean
example: false
error:
type: string
example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions."
code:
type: string
example: youtube_analytics_scope_missing
scopeStatus:
type: object
properties:
hasAnalyticsScope:
type: boolean
example: false
requiresReauthorization:
type: boolean
example: true
reauthorizeUrl:
type: string
format: uri
description: URL to redirect user for reauthorization
InstagramAccountInsightsResponse:
type: object
description: |
Shared account-insights response envelope used by every platform-level
analytics endpoint (/v1/analytics/{facebook|instagram|youtube|linkedin|tiktok}/*).
The name is historical - the shape was first shipped for Instagram and every
new platform endpoint reuses it for response-shape consistency. The platform
field echoes back which platform served the response.
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
description: Platform that served this response.
enum: [facebook, instagram, youtube, linkedin, tiktok]
dateRange:
type: object
properties:
since:
type: string
format: date
until:
type: string
format: date
metricType:
type: string
enum: [time_series, total_value]
breakdown:
type: string
description: Breakdown dimension used (only present when breakdown was requested)
metrics:
type: object
description: |
Object keyed by metric name. For time_series: each metric has "total" (number) and "values" (array of {date, value}).
For total_value: each metric has "total" (number) and optionally "breakdowns" (array of {dimension, value}).
additionalProperties:
type: object
properties:
total:
type: number
description: Sum or aggregate value for the metric
values:
type: array
description: Daily values (only for time_series)
items:
type: object
properties:
date:
type: string
format: date
value:
type: number
breakdowns:
type: array
description: Breakdown values (only for total_value with breakdown)
items:
type: object
properties:
dimension:
type: string
value:
type: number
dataDelay:
type: string
example: "Data may be delayed up to 48 hours"
InstagramDemographicsResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
example: "instagram"
metric:
type: string
enum: [follower_demographics, engaged_audience_demographics]
timeframe:
type: string
enum: [this_week, this_month]
description: The timeframe used for demographic data
demographics:
type: object
description: Object keyed by breakdown dimension (age, city, country, gender)
additionalProperties:
type: array
items:
type: object
properties:
dimension:
type: string
description: The dimension value (e.g., "25-34", "US", "M")
value:
type: number
description: Count of accounts in this dimension
note:
type: string
example: "Demographics show top 45 entries per dimension. Requires 100+ followers."
YouTubeDemographicsResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
example: "youtube"
demographics:
type: object
description: Object keyed by breakdown dimension (age, gender, country)
additionalProperties:
type: array
items:
type: object
properties:
dimension:
type: string
description: The dimension value (e.g., "25-34", "US", "male")
value:
type: number
description: Viewer percentage (age/gender) or view count (country)
dateRange:
type: object
properties:
startDate:
type: string
example: "2026-01-01"
endDate:
type: string
example: "2026-03-31"
note:
type: string
example: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
Webhook:
type: object
description: Individual webhook configuration for receiving real-time notifications
properties:
_id:
type: string
description: Unique webhook identifier
name:
type: string
description: Webhook name (for identification)
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL
secret:
type: string
description: Secret key for HMAC-SHA256 signature (not returned in responses for security)
events:
type: array
items:
type: string
enum: [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]
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
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
platformPostId:
type: string
publishedUrl:
type: string
error:
type: string
timestamp:
type: string
format: date-time
WebhookPayloadAccountConnected:
type: object
description: Webhook payload for account connected events
required: [id, event, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [account.connected]
account:
type: object
required: [accountId, profileId, platform, username]
properties:
accountId:
type: string
description: The account's unique identifier (same as used in /v1/accounts/{accountId})
profileId:
type: string
description: The profile's unique identifier this account belongs to
platform:
type: string
username:
type: string
displayName:
type: string
timestamp:
type: string
format: date-time
WebhookPayloadAccountDisconnected:
type: object
description: Webhook payload for account disconnected events
required: [id, event, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [account.disconnected]
account:
type: object
required: [accountId, profileId, platform, username, disconnectionType, reason]
properties:
accountId:
type: string
description: The account's unique identifier (same as used in /v1/accounts/{accountId})
profileId:
type: string
description: The profile's unique identifier this account belongs to
platform:
type: string
username:
type: string
displayName:
type: string
disconnectionType:
type: string
enum: [intentional, unintentional]
description: Whether the disconnection was intentional (user action) or unintentional (token expired/revoked)
reason:
type: string
description: Human-readable reason for the disconnection
timestamp:
type: string
format: date-time
WebhookPayloadAccountAdsInitialSyncCompleted:
type: object
description: |
Webhook payload for `account.ads.initial_sync_completed` events.
Fired once per ads-enabled account when the initial discovery + 90-day
ad backfill finishes (whether it succeeded fully, partially, or failed).
required: [id, event, account, sync, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [account.ads.initial_sync_completed]
account:
type: object
required: [accountId, profileId, platform, username]
properties:
accountId:
type: string
description: The account's unique identifier (same as used in /v1/accounts/{accountId})
profileId:
type: string
description: The profile's unique identifier this account belongs to
platform:
type: string
username:
type: string
displayName:
type: string
platformUserId:
type: string
description: The platform-side account/ad-account ID (e.g. Meta ad account ID).
profilePicture:
type: string
format: uri
description: URL of the account's profile picture, when available.
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.
timestamp:
type: string
format: date-time
WebhookPayloadComment:
type: object
description: Webhook payload for comment received events (Instagram, Facebook, Twitter/X, YouTube, LinkedIn, Bluesky, Reddit)
required: [id, event, comment, post, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [comment.received]
comment:
type: object
required: [id, postId, platformPostId, platform, text, author, createdAt, isReply, parentCommentId]
properties:
id:
type: string
description: Platform comment ID
postId:
type: string
nullable: true
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
nullable: true
createdAt:
type: string
format: date-time
isReply:
type: boolean
description: Whether this is a reply to another comment
parentCommentId:
type: string
nullable: true
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).
post:
type: object
required: [id, platformPostId]
properties:
id:
type: string
nullable: true
description: Internal post ID (null for posts not published through Zernio)
platformPostId:
type: string
description: Platform's post ID
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
description: Social account ID
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
ReviewWebhookReview:
type: object
description: Review data shared by review.new and review.updated payloads.
required: [id, platform, rating, text, reviewer, createdAt, hasReply]
properties:
id:
type: string
description: Platform review ID (e.g. "accounts/123/locations/456/reviews/789" for Google Business).
platform:
type: string
enum: [googlebusiness]
description: Platform the review originated on. Currently Google Business Profile only.
rating:
type: integer
minimum: 1
maximum: 5
description: Star rating the reviewer gave.
text:
type: string
description: Review text content. May be empty if the reviewer left only a rating.
reviewer:
type: object
required: [id, name, profileImage]
properties:
id:
type: string
nullable: true
description: Platform reviewer ID. Null when the platform does not expose it (common on Google Business anonymous reviews).
name:
type: string
profileImage:
type: string
nullable: true
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
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
WebhookPayloadReviewUpdated:
type: object
description: |
Webhook payload for the review.updated event. Fired when the reviewer edits
their text or rating, or when a reply is added (via the API or directly on the
platform). Same shape as review.new. When a reply is present, review.hasReply
is true and review.reply is populated.
required: [id, event, review, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [review.updated]
review:
$ref: '#/components/schemas/ReviewWebhookReview'
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
platform:
type: string
username:
type: string
timestamp:
type: string
format: date-time
# ─── Shared sub-schemas for inbox lifecycle webhook payloads ────────────
# WebhookPayloadMessageEdited / Deleted / DeliveryStatus reference these
# as $refs. They were added as top-level named schemas (rather than nested
# refs into WebhookPayloadMessage.properties) because openapi-typescript
# and datamodel-code-generator can't resolve nested-property refs — the
# SDK regen fails with "Cannot find name 'WebhookPayloadMessage_properties_message'".
InboxWebhookMessage:
type: object
description: The message object included in inbox webhook payloads.
required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
properties:
id:
type: string
description: Internal message ID
conversationId:
type: string
description: Internal conversation ID
platform:
type: string
enum: [instagram, facebook, telegram, whatsapp]
platformMessageId:
type: string
description: Platform's message ID
direction:
type: string
enum: [incoming, outgoing]
text:
type: string
nullable: true
description: Message text content (retained on deleted messages for API consumers; Zernio dashboard UI hides this)
attachments:
type: array
items:
type: object
required: [type, url]
properties:
type:
type: string
description: Attachment type (image, video, file, sticker, audio)
url:
type: string
description: Attachment URL (may expire for Meta platforms)
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
description: |
Sender's platform identifier. For WhatsApp this is the phone number
(without leading `+`) when available, otherwise the `businessScopedUserId`.
For other platforms, the platform's own user ID.
name:
type: string
username:
type: string
picture:
type: string
phoneNumber:
type: string
nullable: true
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, nullable: true }
isFollowing: { type: boolean, nullable: true }
followerCount: { type: integer, nullable: true }
isVerified: { type: boolean, nullable: true }
sentAt:
type: string
format: date-time
isRead:
type: boolean
InboxWebhookConversation:
type: object
description: The conversation context included in inbox webhook payloads.
required: [id, platformConversationId, status]
properties:
id: { type: string }
platformConversationId: { type: string }
participantId: { type: string }
participantName: { type: string }
participantUsername: { type: string }
participantPicture: { type: string }
status:
type: string
enum: [active, archived]
InboxWebhookAccount:
type: object
description: The account context included in inbox webhook payloads.
required: [id, platform, username]
properties:
id:
type: string
description: Social account ID
platform: { type: string }
username: { type: string }
displayName: { type: string }
# ────────────────────────────────────────────────────────────────────────
WebhookPayloadMessage:
type: object
description: Webhook payload for message received events
required: [id, event, message, conversation, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [message.received]
message:
type: object
required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
properties:
id:
type: string
description: Internal message ID
conversationId:
type: string
description: Internal conversation ID
platform:
type: string
enum: [instagram, facebook, telegram, whatsapp]
platformMessageId:
type: string
description: Platform's message ID
direction:
type: string
enum: [incoming, outgoing]
text:
type: string
nullable: true
description: Message text content
attachments:
type: array
items:
type: object
required: [type, url]
properties:
type:
type: string
description: Attachment type (image, video, file, sticker, audio)
url:
type: string
description: Attachment URL (may expire for Meta platforms)
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
description: |
Sender's platform identifier. For WhatsApp this is the phone
number (without leading `+`) when available, otherwise the
`businessScopedUserId`.
name:
type: string
username:
type: string
picture:
type: string
phoneNumber:
type: string
nullable: true
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
nullable: true
description: Whether the sender follows your Instagram business account
isFollowing:
type: boolean
nullable: true
description: Whether your Instagram business account follows the sender
followerCount:
type: integer
nullable: true
description: The sender's follower count on Instagram
isVerified:
type: boolean
nullable: true
description: Whether the sender is a verified Instagram user
sentAt:
type: string
format: date-time
isRead:
type: boolean
conversation:
type: object
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]
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
description: Social account ID
platform:
type: string
username:
type: string
displayName:
type: string
metadata:
type: object
nullable: true
description: Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback)
properties:
quickReplyPayload:
type: string
description: Payload from a quick reply tap (Facebook/Instagram Messenger).
postbackPayload:
type: string
description: Payload from a postback button tap (Facebook/Instagram Messenger).
postbackTitle:
type: string
description: Title of the tapped postback button (Facebook/Instagram Messenger).
callbackData:
type: string
description: Callback data from an inline keyboard button tap (Telegram).
interactiveType:
type: string
enum: [button_reply, list_reply, nfm_reply]
description: |
WhatsApp only. Which kind of interactive reply the user sent:
`button_reply` (tap on an interactive button), `list_reply` (tap on a
list row), or `nfm_reply` (a WhatsApp Flow submission).
interactiveId:
type: string
description: |
WhatsApp only. The `id` of the tapped button or list row, matching the
`id` you supplied when the message was sent. Not set for Flow responses.
buttonPayload:
type: string
description: |
WhatsApp only. Payload attached to a tapped template button. Template
buttons emit a plain `button` webhook (not an interactive reply), so
`interactiveType` is empty while this field is populated.
flowResponseJson:
type: string
description: |
WhatsApp only. Raw `nfm_reply.response_json` string returned by a
Flow submission. Useful if you need the exact wire payload; for
typed access use `flowResponseData` instead.
flowResponseData:
type: object
additionalProperties: true
description: |
WhatsApp only. Parsed Flow response JSON. Populated when
`flowResponseJson` is valid JSON; otherwise omitted. Keys and
value types depend on the specific Flow that was submitted.
referral:
type: object
description: |
WhatsApp only. Click-to-WhatsApp (CTWA) ad attribution. Present
only on the FIRST inbound message after a user reaches the
business via a CTWA ad. Forwarded verbatim from Meta's referral
envelope so it can be replayed to the Conversions API for
Business Messaging. Attribution window is 7 days from click.
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
timestamp:
type: string
format: date-time
WebhookPayloadMessageSent:
type: object
description: Webhook payload for message sent events (fired when a message is sent via the API)
required: [id, event, message, conversation, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [message.sent]
message:
type: object
required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
properties:
id:
type: string
description: Internal message ID
conversationId:
type: string
description: Internal conversation ID
platform:
type: string
enum: [instagram, facebook, telegram, whatsapp]
platformMessageId:
type: string
description: Platform's message ID
direction:
type: string
enum: [incoming, outgoing]
text:
type: string
nullable: true
description: Message text content
attachments:
type: array
items:
type: object
required: [type, url]
properties:
type:
type: string
description: Attachment type (image, video, file, sticker, audio)
url:
type: string
description: Attachment URL (may expire for Meta platforms)
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
name:
type: string
username:
type: string
picture:
type: string
sentAt:
type: string
format: date-time
isRead:
type: boolean
conversation:
type: object
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]
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
description: Social account ID
platform:
type: string
username:
type: string
displayName:
type: string
timestamp:
type: string
format: date-time
WebhookPayloadMessageEdited:
type: object
description: |
Webhook payload for message.edited events. Fires when the sender
edits a previously-sent message. Supported platforms: Instagram,
Facebook Messenger, Telegram. The message object reflects the
LATEST state; editHistory contains every prior version in order
(oldest first), so the last entry is the version immediately before
the current content.
required: [id, event, message, editHistory, editCount, editedAt, conversation, account, timestamp]
properties:
id: { type: string }
event: { type: string, enum: [message.edited] }
message:
$ref: '#/components/schemas/InboxWebhookMessage'
editHistory:
type: array
description: Prior versions of the message, oldest first.
items:
type: object
required: [text, attachments, editedAt]
properties:
text:
type: string
nullable: true
attachments:
type: array
items:
type: object
properties:
type: { type: string }
url: { type: string }
payload: { type: object }
editedAt:
type: string
format: date-time
editCount:
type: integer
description: Total number of edits applied to this message.
editedAt:
type: string
format: date-time
description: When the most recent edit happened.
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp: { type: string, format: date-time }
WebhookPayloadMessageDeleted:
type: object
description: |
Webhook payload for message.deleted events. Fires when the sender
deletes (unsends) a message. Supported platforms: Instagram (incoming
unsend) and WhatsApp (when the business deletes an outgoing message
via the Cloud API).
The message.text and message.attachments fields retain the content
that existed before the delete. The Zernio dashboard UI does not show
this content, but authorized API consumers may access it for
moderation, compliance, or archival use cases.
required: [id, event, message, deletedAt, conversation, account, timestamp]
properties:
id: { type: string }
event: { type: string, enum: [message.deleted] }
message:
$ref: '#/components/schemas/InboxWebhookMessage'
deletedAt:
type: string
format: date-time
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp: { type: string, format: date-time }
WebhookPayloadMessageDeliveryStatus:
type: object
description: |
Shared payload for message.delivered, message.read, and
message.failed events. Fires when the platform reports a new
delivery state for an outgoing message.
Platform support:
* message.delivered — WhatsApp, Facebook Messenger.
* message.read — WhatsApp, Facebook Messenger, Instagram.
* message.failed — WhatsApp only (other platforms don't expose
per-message failure via webhook).
required: [id, event, message, statusAt, conversation, account, timestamp]
properties:
id: { type: string }
event:
type: string
enum: [message.delivered, message.read, message.failed]
message:
$ref: '#/components/schemas/InboxWebhookMessage'
statusAt:
type: string
format: date-time
description: When the platform reported this status.
error:
type: object
nullable: true
description: Populated only on message.failed.
properties:
code: { type: integer }
title: { type: string }
message: { type: string }
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp: { type: string, format: date-time }
WebhookPayloadTest:
type: object
description: Webhook payload for test deliveries
required: [id, event, message, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [webhook.test]
message:
type: string
description: Human-readable test message
timestamp:
type: string
format: date-time
GeoRestriction:
type: object
description: >
Country-level geo-restriction (allowlist). When set, the post is only visible to users in the
specified countries. Supported on Facebook (feed posts, videos, reels), X/Twitter (media-level
restriction), and LinkedIn (organization pages only, min 300 targeted followers). Ignored on
unsupported platforms. Stories (Facebook, Instagram) do not support geo-restriction.
properties:
countries:
type: array
minItems: 1
maxItems: 25
items:
type: string
pattern: '^[A-Z]{2}$'
example: US
description: >
ISO 3166-1 alpha-2 country codes (uppercase). Only users in these countries can see the post.
Maximum 25 countries per post. Example: ["US", "CA", "GB", "ES"].
example: ["US", "CA", "GB"]
required:
- countries
MediaItem:
type: object
description: Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed).
properties:
type:
type: string
enum: [image, video, gif, document]
url:
type: string
format: uri
title:
type: string
description: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename.
filename:
type: string
size:
type: integer
description: Optional file size in bytes
mimeType:
type: string
description: Optional MIME type (e.g. image/jpeg, video/mp4)
thumbnail:
type: string
format: uri
description: Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, and regular video uploads. Max 10MB, JPG/PNG recommended.
instagramThumbnail:
type: string
format: uri
description: "Custom cover image URL for Instagram Reels. Can also be set via platformSpecificData.instagramThumbnail or platformSpecificData.reelCover. Resolution order: this field > platformSpecificData.instagramThumbnail > platformSpecificData.reelCover > platformSpecificData.thumbnailUrl (legacy)."
tiktokProcessed:
type: boolean
description: Internal flag indicating the image was resized for TikTok
PlatformTarget:
type: object
properties:
platform:
type: string
example: twitter
description: "Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness, telegram"
accountId:
oneOf:
- type: string
- $ref: '#/components/schemas/SocialAccount'
customContent:
type: string
description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters).
customMedia:
type: array
items:
$ref: '#/components/schemas/MediaItem'
scheduledFor:
type: string
format: date-time
description: Optional per-platform scheduled time override (uses post.scheduledFor when omitted)
platformSpecificData:
description: Platform-specific overrides and options.
oneOf:
- $ref: '#/components/schemas/TwitterPlatformData'
- $ref: '#/components/schemas/ThreadsPlatformData'
- $ref: '#/components/schemas/FacebookPlatformData'
- $ref: '#/components/schemas/InstagramPlatformData'
- $ref: '#/components/schemas/LinkedInPlatformData'
- $ref: '#/components/schemas/PinterestPlatformData'
- $ref: '#/components/schemas/YouTubePlatformData'
- $ref: '#/components/schemas/GoogleBusinessPlatformData'
- $ref: '#/components/schemas/TikTokPlatformData'
- $ref: '#/components/schemas/TelegramPlatformData'
- $ref: '#/components/schemas/SnapchatPlatformData'
- $ref: '#/components/schemas/RedditPlatformData'
- $ref: '#/components/schemas/BlueskyPlatformData'
- $ref: '#/components/schemas/DiscordPlatformData'
additionalProperties: true
status:
type: string
example: pending
description: "Platform-specific status: pending, publishing, published, failed"
platformPostId:
type: string
description: The native post ID on the platform (populated after successful publish)
example: "1234567890123456789"
platformPostUrl:
type: string
format: uri
description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time.
example: "https://twitter.com/acmecorp/status/1234567890123456789"
publishedAt:
type: string
format: date-time
description: Timestamp when the post was published to this platform
errorMessage:
type: string
description: Human-readable error message when status is failed. Contains platform-specific error details explaining why the publish failed.
errorCategory:
type: string
enum: [auth_expired, user_content, user_abuse, account_issue, platform_rejected, platform_error, system_error, unknown]
description: "Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), system_error (Zernio infra), unknown"
errorSource:
type: string
enum: [user, platform, system]
description: "Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare)"
Post:
type: object
properties:
_id: { type: string }
userId:
oneOf:
- type: string
- $ref: '#/components/schemas/User'
title:
type: string
description: |
YouTube: title must be ≤ 100 characters.
content: { type: string }
mediaItems:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
platforms:
type: array
items: { $ref: '#/components/schemas/PlatformTarget' }
scheduledFor: { type: string, format: date-time }
timezone: { type: string }
status: { type: string, enum: [draft, scheduled, publishing, published, failed, partial] }
tags:
type: array
description: "YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed."
items: { type: string }
hashtags:
type: array
items: { type: string }
mentions:
type: array
description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
items: { type: string }
visibility: { type: string, enum: [public, private, unlisted] }
metadata:
type: object
additionalProperties: true
recycling:
$ref: '#/components/schemas/RecyclingState'
recycledFromPostId:
type: string
description: ID of the original post if this post was created via recycling
queuedFromProfile:
type: string
description: Profile ID if the post was scheduled via the queue
queueId:
type: string
description: Queue ID if the post was scheduled via a specific queue
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
RecyclingConfig:
type: object
description: |
Configure automatic post recycling (reposting at regular intervals).
After the post is published, the system creates new scheduled copies at the
specified interval until expiration conditions are met. Supports weekly or
monthly intervals. Maximum 10 active recycling posts per account.
YouTube and TikTok platforms are excluded from recycling.
Content variations are recommended for Twitter and Pinterest to avoid duplicate flags.
properties:
enabled:
type: boolean
default: true
description: Set to false to disable recycling on this post
gap:
type: integer
minimum: 1
description: Number of interval units between each repost. Required when enabling recycling.
example: 2
gapFreq:
type: string
enum: [week, month]
default: month
description: Interval unit for the gap. Defaults to 'month'.
startDate:
type: string
format: date-time
description: When to start the recycling cycle. Defaults to the post's scheduledFor date.
expireCount:
type: integer
minimum: 1
description: Stop recycling after this many copies have been created
example: 5
expireDate:
type: string
format: date-time
description: Stop recycling after this date, regardless of count
contentVariations:
type: array
items:
type: string
maxItems: 20
description: |
Array of content variations for recycled copies. On each recycle, the next
variation is used in round-robin order. Recommended for Twitter and Pinterest
to avoid duplicate content flags. If omitted, the original post content is
used for all recycled copies. Send an empty array [] to clear existing
variations. Must have 2+ entries when setting variations. Platform-level
customContent still overrides the base content per platform.
RecyclingState:
type: object
description: Current recycling configuration and state on a post
properties:
enabled:
type: boolean
description: Whether recycling is currently active
gap:
type: integer
description: Number of interval units between reposts
gapFreq:
type: string
enum: [week, month]
description: Interval unit (week or month)
startDate: { type: string, format: date-time }
expireCount: { type: integer }
expireDate: { type: string, format: date-time }
contentVariations:
type: array
items:
type: string
description: Content variations for recycled copies (if configured)
contentVariationIndex:
type: integer
description: Current position in the content variations rotation (read-only)
recycleCount:
type: integer
description: How many recycled copies have been created so far (read-only)
nextRecycleAt:
type: string
format: date-time
description: When the next recycled copy will be created (read-only)
lastRecycledAt:
type: string
format: date-time
description: When the last recycled copy was created (read-only)
TwitterPlatformData:
type: object
properties:
replyToTweetId:
type: string
description: ID of an existing tweet to reply to. The published tweet will appear as a reply in that tweet's thread. For threads, only the first tweet replies to the target; subsequent tweets chain normally.
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'
description: >
X (Twitter) geo-restriction applies at the media level. Media in geo-restricted tweets will be
hidden for users outside the specified countries; the tweet text itself remains visible globally.
Requires media to be attached (ignored for text-only tweets).
ThreadsPlatformData:
type: object
properties:
topic_tag:
type: string
minLength: 1
maxLength: 50
description: Topic tag for post categorization and discoverability on Threads. Must be 1-50 characters, cannot contain periods (.) or ampersands (&). Overrides auto-extraction from content hashtags when provided.
threadItems:
type: array
description: >
Complete sequence of posts in a Threads thread. The first item becomes the root post,
subsequent items are chained as replies. When threadItems is provided, the top-level
content field is used only for display and search purposes, it is NOT published.
You must include your first post as threadItems[0].
items:
type: object
properties:
content: { type: string }
mediaItems:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
description: Up to 10 images per carousel (no videos). Videos must be H.264/AAC MP4, max 5 min. Images JPEG/PNG, max 8 MB. Use threadItems for reply chains.
FacebookPlatformData:
type: object
properties:
draft:
type: boolean
description: When true, creates the post as an unpublished draft visible in Facebook Publishing Tools instead of publishing immediately. Supported for feed posts (text, link, image, video) and reels. Not supported for stories. Drafts expire after ~30 days.
default: false
contentType:
type: string
enum: [story, reel]
description: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted.
title:
type: string
description: Reel title (only for contentType=reel). Separate from the caption/content field.
firstComment:
type: string
description: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true.
pageId:
type: string
description: Target Facebook Page ID for multi-page posting. If omitted, uses the default page. Use GET /v1/accounts/{id}/facebook-page to list pages.
geoRestriction:
$ref: '#/components/schemas/GeoRestriction'
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.
InstagramPlatformData:
type: object
properties:
contentType:
type: string
enum: [story]
description: Set to 'story' to publish as a Story. Default posts become Reels or feed depending on media.
shareToFeed:
type: boolean
default: true
description: For Reels only. When true (default), the Reel appears on both the Reels tab and your main profile feed. Set to false to post to the Reels tab only.
collaborators:
type: array
items: { type: string }
description: Up to 3 Instagram usernames to invite as collaborators (feed/Reels only)
firstComment:
type: string
description: Optional first comment to add after the post is created (not applied to Stories)
trialParams:
type: object
description: Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels.
properties:
graduationStrategy:
type: string
enum: [MANUAL, SS_PERFORMANCE]
description: "MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers)"
userTags:
type: array
description: Tag Instagram users in photos by username and position. Not supported for stories or videos. For carousels, use mediaIndex to target specific slides (defaults to 0). Tags on video items are silently skipped.
items:
type: object
required: [username, x, y]
properties:
username:
type: string
description: Instagram username (@ symbol is optional and will be removed automatically)
example: friend_username
x:
type: number
minimum: 0
maximum: 1
description: X coordinate position from left edge (0.0 = left, 0.5 = center, 1.0 = right)
example: 0.5
y:
type: number
minimum: 0
maximum: 1
description: Y coordinate position from top edge (0.0 = top, 0.5 = center, 1.0 = bottom)
example: 0.5
mediaIndex:
type: integer
minimum: 0
description: Zero-based index of the carousel item to tag. Defaults to 0. Tags on video items or out-of-range indices are ignored.
example: 0
audioName:
type: string
description: Custom name for original audio in Reels. Replaces the default "Original Audio" label. Can only be set once.
example: "My Podcast Intro"
thumbOffset:
type: integer
minimum: 0
description: Millisecond offset from video start for the Reel cover frame. Ignored when instagramThumbnail or reelCover is provided. Defaults to 0.
example: 5000
instagramThumbnail:
type: string
format: uri
description: Custom cover image URL for Instagram Reels (JPG or PNG, publicly accessible). Overrides thumbOffset when provided. Also accepted as reelCover (alias).
reelCover:
type: string
format: uri
description: Alias for instagramThumbnail. If both are provided, instagramThumbnail takes priority.
description: Feed aspect ratio 0.8-1.91, carousels up to 10 items, stories require media (no captions). User tag coordinates 0.0-1.0 from top-left. Images over 8 MB and videos over platform limits are auto-compressed.
LinkedInPlatformData:
type: object
properties:
documentTitle:
type: string
description: Title displayed on LinkedIn document (PDF/carousel) posts. Required by LinkedIn for document posts. If omitted, falls back to the media item title, then the filename.
organizationUrn:
type: string
description: Target LinkedIn Organization URN (e.g. "urn:li:organization:123456789"). If omitted, uses the default org. Use GET /v1/accounts/{id}/linkedin-organizations to list orgs.
firstComment:
type: string
description: Optional first comment to add after the post is created
disableLinkPreview:
type: boolean
description: Set to true to disable automatic link previews for URLs in the post content (default is false)
geoRestriction:
$ref: '#/components/schemas/GeoRestriction'
description: >
Up to 20 images, no multi-video. Single PDF supported (max 100MB). Link previews auto-generated
when no media attached. Use organizationUrn for multi-org posting. Geo-restriction only works for
organization pages (not personal profiles) and requires the targeted audience to exceed 300 followers.
PinterestPlatformData:
type: object
properties:
title:
type: string
maxLength: 100
description: Pin title. Defaults to first line of content or "Pin". Must be ≤ 100 characters.
boardId:
type: string
description: Target Pinterest board ID. If omitted, the first available board is used.
link:
type: string
format: uri
description: Destination link (pin URL)
coverImageUrl:
type: string
format: uri
description: Optional cover image for video pins
coverImageKeyFrameTime:
type: integer
description: Optional key frame time in seconds for derived video cover
YouTubePlatformData:
type: object
properties:
title:
type: string
maxLength: 100
description: Video title. Defaults to first line of content or "Untitled Video". Must be ≤ 100 characters.
visibility:
type: string
enum: [public, private, unlisted]
default: public
description: "Video visibility: public (default, anyone can watch), unlisted (link only), private (invite only)"
madeForKids:
type: boolean
default: false
description: COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). Defaults to false. YouTube may block views if not explicitly set.
firstComment:
type: string
maxLength: 10000
description: Optional first comment to post immediately after video upload. Up to 10,000 characters (YouTube's comment limit).
containsSyntheticMedia:
type: boolean
default: false
description: AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label.
categoryId:
type: string
default: '22'
description: "YouTube video category ID. Defaults to 22 (People & Blogs). Common: 1 (Film), 2 (Autos), 10 (Music), 15 (Pets), 17 (Sports), 20 (Gaming), 23 (Comedy), 24 (Entertainment), 25 (News), 26 (Howto), 27 (Education), 28 (Science & Tech)."
playlistId:
type: string
description: "Optional YouTube playlist ID to add the video to after upload (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Works for both immediate and scheduled uploads. Quota cost: 50 YouTube API units per call."
description: Videos under 3 min auto-detected as Shorts. Custom thumbnails for regular videos only. Scheduled videos are uploaded immediately with the specified visibility.
GoogleBusinessPlatformData:
type: object
properties:
locationId:
type: string
description: Target GBP location ID (e.g. "locations/123456789"). If omitted, uses the default location. Use GET /v1/accounts/{id}/gmb-locations to list locations.
languageCode:
type: string
description: BCP 47 language code (e.g. "en", "de", "es"). Auto-detected if omitted. Set explicitly for short or mixed-language posts.
example: "de"
topicType:
type: string
enum: [STANDARD, EVENT, OFFER]
default: STANDARD
description: "Post type. STANDARD is a regular update. EVENT requires the event object. OFFER requires the offer object. Defaults to STANDARD if omitted."
callToAction:
type: object
description: Optional call-to-action button displayed on the post
properties:
type:
type: string
enum: [LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL]
description: "Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL"
url:
type: string
format: uri
description: Destination URL for the CTA button (required when callToAction is provided)
required: [type, url]
event:
type: object
description: "Event details. Required when topicType is EVENT. Google returns 400 if omitted for EVENT posts."
properties:
title:
type: string
description: Event name (displayed as the event heading on Google Search and Maps)
example: "Grand Opening Weekend"
schedule:
type: object
description: "Event date/time range. Uses Google's date format (NOT ISO 8601)."
properties:
startDate:
type: object
description: "Event start date as { year, month, day }"
properties:
year:
type: integer
example: 2026
month:
type: integer
minimum: 1
maximum: 12
example: 5
day:
type: integer
minimum: 1
maximum: 31
example: 15
required: [year, month, day]
startTime:
type: object
description: "Optional start time as { hours, minutes } in 24h format"
properties:
hours:
type: integer
minimum: 0
maximum: 23
example: 9
minutes:
type: integer
minimum: 0
maximum: 59
example: 0
endDate:
type: object
description: "Event end date as { year, month, day }"
properties:
year:
type: integer
example: 2026
month:
type: integer
minimum: 1
maximum: 12
example: 5
day:
type: integer
minimum: 1
maximum: 31
example: 16
required: [year, month, day]
endTime:
type: object
description: "Optional end time as { hours, minutes } in 24h format"
properties:
hours:
type: integer
minimum: 0
maximum: 23
example: 17
minutes:
type: integer
minimum: 0
maximum: 59
example: 0
required: [startDate, endDate]
required: [title, schedule]
offer:
type: object
description: "Offer details. Required when topicType is OFFER. All fields are optional per Google's API, but at least one is recommended."
properties:
offerType:
type: string
enum: [OFFER, BUY_ONE_GET_ONE]
description: Type of offer
redeemOnlineUrl:
type: string
format: uri
description: URL where the offer can be redeemed online
termsConditions:
type: string
description: Terms and conditions for the offer
couponCode:
type: string
description: Coupon code for the offer
example: "SAVE20"
description: "Text and single image only (no videos). Supports STANDARD, EVENT, OFFER, and ALERT post types. Posts appear on GBP, Google Search, and Maps. Use locationId for multi-location posting. Schedule dates accept both ISO 8601 strings (e.g. '2026-04-15T09:00:00Z') and Google's native {year, month, day} objects."
TikTokPlatformData:
type: object
description: |
Photo carousels up to 35 images. Video titles up to 2200 chars, photo titles truncated to 90 chars.
privacyLevel must match creator_info options. Both camelCase and snake_case accepted.
Creator Inbox (draft mode): Set draft: true to send content to the TikTok Creator Inbox
instead of publishing immediately. The creator receives an inbox notification and completes
the post using TikTok's editing flow. This maps to TikTok's post_mode: "MEDIA_UPLOAD" internally.
Important: The field publish_type is NOT supported. Use draft: true for Creator Inbox flow.
Photo drafts use the /v2/post/publish/content/init/ endpoint with post_mode: "MEDIA_UPLOAD".
Video drafts use the dedicated /v2/post/publish/inbox/video/init/ endpoint.
When draft: true, the video.upload scope is required. When draft is false or omitted
(direct post), the video.publish scope is required. For Creator Inbox, TikTok app version
must be 31.8 or higher.
properties:
draft:
type: boolean
description: |
When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing
immediately. The creator receives an inbox notification to complete posting via TikTok's
editing flow. Maps to TikTok API post_mode: "MEDIA_UPLOAD" (photos) or the dedicated
inbox endpoint (videos). When false or omitted, publishes directly via post_mode: "DIRECT_POST".
Note: publish_type is not a supported field. Use this field instead.
privacyLevel:
type: string
description: One of the values returned by the TikTok creator info API for the account
allowComment:
type: boolean
description: Allow comments on the post
allowDuet:
type: boolean
description: Allow duets (required for video posts)
allowStitch:
type: boolean
description: Allow stitches (required for video posts)
commercialContentType:
type: string
enum: [none, brand_organic, brand_content]
description: Type of commercial content disclosure
brandPartnerPromote:
type: boolean
description: Whether the post promotes a brand partner
isBrandOrganicPost:
type: boolean
description: Whether the post is a brand organic post
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"
nativeVideo:
type: boolean
description: >
Controls Reddit's native video upload flow. When true (default for video mediaItems),
the video is uploaded to Reddit's CDN and submitted with kind=video so it renders as
an embedded Reddit video player. Reddit transcodes server-side (1080p/30fps cap). Set
to false to fall back to a legacy link post. If the subreddit blocks video posts, the
upload falls back to a link post automatically.
default: true
videogif:
type: boolean
description: When true (and nativeVideo is active), submits the video as a silent videogif (kind=videogif). Use for short looping clips without audio.
videoPosterUrl:
type: string
format: uri
description: Optional poster/thumbnail image URL for native video posts. If omitted, the first frame of the video is extracted and used automatically.
description: Posts are either link (with URL/media), native video (via nativeVideo), or self (text-only). Use forceSelf to override. Subreddit defaults to the account's configured one. Some subreddits require a flair.
BlueskyPlatformData:
type: object
properties:
threadItems:
type: array
description: >
Complete sequence of posts in a Bluesky thread. The first item becomes the root post,
subsequent items are chained as replies. When threadItems is provided, the top-level
content field is used only for display and search purposes, it is NOT published.
You must include your first post as threadItems[0].
items:
type: object
properties:
content:
type: string
mediaItems:
type: array
items:
$ref: '#/components/schemas/MediaItem'
description: |
Bluesky post settings. Supports text posts with up to 4 images or a single video. threadItems creates a reply chain (Bluesky thread). Images exceeding 1MB are automatically compressed. Alt text supported via mediaItem properties.
DiscordPlatformData:
type: object
required: [channelId]
properties:
channelId:
type: string
description: Target channel snowflake ID. Determines which channel in the connected server receives the message.
example: "1234567890123456789"
embeds:
type: array
description: Up to 10 Discord embed objects (combined max 6,000 characters across all embeds). Sent alongside or instead of plain-text content.
maxItems: 10
items:
type: object
properties:
title:
type: string
description: Embed title (max 256 chars)
description:
type: string
description: Embed body text (max 4,096 chars)
url:
type: string
description: URL the title links to
color:
type: integer
description: Embed accent color as decimal integer (e.g. 5814783 for blue). Convert hex to decimal.
image:
type: object
properties:
url: { type: string }
thumbnail:
type: object
properties:
url: { type: string }
footer:
type: object
properties:
text: { type: string, description: Footer text (max 2,048 chars) }
icon_url: { type: string }
author:
type: object
properties:
name: { type: string, description: Author name (max 256 chars) }
url: { type: string }
icon_url: { type: string }
fields:
type: array
description: Up to 25 fields per embed
maxItems: 25
items:
type: object
required: [name, value]
properties:
name: { type: string, description: Field name (max 256 chars) }
value: { type: string, description: Field value (max 1,024 chars) }
inline: { type: boolean, description: Display fields side-by-side }
poll:
type: object
description: Native Discord poll. Cannot be combined with media attachments in the same message.
properties:
question:
type: object
required: [text]
properties:
text: { type: string, description: Poll question (max 300 chars) }
answers:
type: array
description: 1-10 answer options
maxItems: 10
items:
type: object
properties:
poll_media:
type: object
properties:
text: { type: string, description: Answer text }
duration:
type: integer
description: Poll duration in hours (1-768). Default 24.
minimum: 1
maximum: 768
allow_multiselect:
type: boolean
description: Allow users to select multiple answers. Default false.
crosspost:
type: boolean
description: Auto-crosspost to every server following this announcement channel (type 5). No-op for regular text channels.
forumThreadName:
type: string
description: Thread title for forum channel posts (type 15). Required when posting to a forum channel.
forumAppliedTags:
type: array
description: Tag snowflake IDs to apply to forum posts. Max 5 tags.
maxItems: 5
items:
type: string
threadFromMessage:
type: object
description: Create a follow-up thread under the published message.
properties:
name:
type: string
description: Thread name (1-100 chars)
autoArchiveDuration:
type: integer
description: Auto-archive after inactivity (minutes)
enum: [60, 1440, 4320, 10080]
rateLimitPerUser:
type: integer
description: Slow-mode duration in seconds (0-21600)
minimum: 0
maximum: 21600
tts:
type: boolean
description: Send as text-to-speech message. Discord reads the message aloud in the channel.
webhookUsername:
type: string
description: Override the webhook display name for this post only (1-80 chars). Falls back to the account-level default set via PATCH /v1/connect/discord.
webhookAvatarUrl:
type: string
description: Override the webhook avatar URL for this post only. Falls back to the account-level default.
description: |
Discord message settings. Supports plain text (2,000 chars), rich embeds (up to 10), native polls, forum posts, threads, and announcement crossposts. Media attachments support images (JPEG, PNG, GIF, WebP), videos (MP4), and documents (up to 10 files, 25 MB each). Webhook identity (username + avatar) can be customized per-account via PATCH /v1/connect/discord or per-post via webhookUsername/webhookAvatarUrl.
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
properties:
_id: { type: string }
platform:
type: string
enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads]
profileId:
oneOf:
- type: string
- $ref: '#/components/schemas/Profile'
username: { type: string }
displayName: { type: string }
profilePicture:
type: string
nullable: true
description: URL to the account's profile picture on the platform. May be null if the platform does not provide one.
profileUrl:
type: string
description: Full profile URL for the connected account on its platform.
isActive: { type: boolean }
followersCount:
type: number
description: Follower count (only included if user has analytics add-on)
followersLastUpdated:
type: string
format: date-time
description: Last time follower count was updated (only included if user has analytics add-on)
parentAccountId:
type: string
nullable: true
description: |
Reference to the parent posting SocialAccount. Set for ads accounts that share
or derive from a posting account's OAuth token. null for standalone ads (Google Ads)
and all posting accounts.
enabled:
type: boolean
description: |
Whether the user explicitly activated this account. false means the account was
created as a side effect (e.g., posting account auto-created when user connected
ads first). Posting UI and scheduler ignore accounts with enabled: false.
metadata:
type: object
description: |
Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes:
- qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN)
- nameStatus: Display name review status (APPROVED, PENDING_REVIEW, DECLINED, or NONE). Messages cannot be sent until the display name is approved by Meta.
- messagingLimitTier: Maximum unique business-initiated conversations per 24h rolling window (TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED). Scales automatically as quality rating improves.
- verifiedName: Meta-verified business display name
- displayPhoneNumber: Formatted phone number (e.g., "+1 555-123-4567")
- wabaId: WhatsApp Business Account ID
- phoneNumberId: Meta phone number ID
AccountWithFollowerStats:
allOf:
- $ref: '#/components/schemas/SocialAccount'
- type: object
properties:
currentFollowers: { type: number, description: Current follower count }
lastUpdated: { type: string, format: date-time }
growth: { type: number, description: Follower change over period }
growthPercentage: { type: number, description: Percentage growth }
dataPoints: { type: number, description: Number of historical snapshots }
accountStats:
type: object
description: |
Platform-specific account stats from the latest daily snapshot.
Fields vary by platform. Only present if metadata has been captured.
properties:
followingCount: { type: number, description: Number of accounts being followed }
mediaCount: { type: number, description: Total media posts (Instagram) }
videoCount: { type: number, description: Total videos (YouTube, TikTok) }
tweetCount: { type: number, description: Total tweets (X/Twitter) }
postsCount: { type: number, description: Total posts (Bluesky) }
pinCount: { type: number, description: Total pins (Pinterest) }
totalViews: { type: number, description: Total channel views (YouTube) }
likesCount: { type: number, description: Total likes received (TikTok) }
monthlyViews: { type: number, description: Monthly profile views (Pinterest) }
listedCount: { type: number, description: Lists the user appears on (X/Twitter) }
boardCount: { type: number, description: Total boards (Pinterest) }
ApiKey:
type: object
properties:
id: { type: string }
name: { type: string }
keyPreview: { type: string }
expiresAt: { type: string, format: date-time }
createdAt: { type: string, format: date-time }
key:
type: string
description: Returned only once, on creation
scope:
type: string
enum: [full, profiles]
description: "'full' grants access to all profiles, 'profiles' restricts to specific profiles"
default: full
profileIds:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
color: { type: string }
description: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'.
permission:
type: string
enum: [read-write, read]
description: "'read-write' allows all operations, 'read' restricts to GET requests only"
default: read-write
UsageStats:
type: object
properties:
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" }
limits:
type: object
properties:
uploads: { type: integer }
profiles: { type: integer }
usage:
type: object
properties:
uploads: { type: integer }
profiles: { type: integer }
lastReset: { type: string, format: date-time }
PostAnalytics:
type: object
properties:
impressions: { type: integer, example: 0 }
reach: { type: integer, example: 0 }
likes: { type: integer, example: 0 }
comments: { type: integer, example: 0 }
shares: { type: integer, example: 0 }
saves: { type: integer, example: 0, description: 'Number of saves/bookmarks (Instagram, Pinterest)' }
clicks: { type: integer, example: 0 }
views: { type: integer, example: 0 }
engagementRate: { type: number, example: 0 }
lastUpdated: { type: string, format: date-time }
PlatformAnalytics:
type: object
properties:
platform: { type: string }
status: { type: string, enum: [published, failed] }
platformPostId: { type: string, nullable: true, description: 'The native post ID on the platform (e.g. Instagram media ID, tweet ID)' }
accountId: { type: string }
accountUsername: { type: string, nullable: true }
analytics:
nullable: true
$ref: '#/components/schemas/PostAnalytics'
syncStatus: { type: string, enum: [synced, pending, unavailable], description: 'Sync state of analytics for this platform' }
platformPostUrl: { type: string, format: uri, nullable: true }
errorMessage: { type: string, nullable: true, description: 'Error details when status is failed' }
AnalyticsOverview:
type: object
properties:
totalPosts: { type: integer }
publishedPosts: { type: integer }
scheduledPosts: { type: integer }
lastSync: { type: string, format: date-time, nullable: true }
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, nullable: true, 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, format: date-time, nullable: true }
analytics:
$ref: '#/components/schemas/PostAnalytics'
platformAnalytics:
type: array
items:
$ref: '#/components/schemas/PlatformAnalytics'
platform: { type: string }
platformPostUrl: { type: string, format: uri, nullable: true }
isExternal: { type: boolean }
syncStatus: { type: string, enum: [synced, pending, partial, unavailable], description: 'Overall sync state across all platforms' }
message: { type: string, nullable: true, description: 'Human-readable status message for pending, partial, or failed states' }
thumbnailUrl: { type: string, format: uri, nullable: true }
mediaType: { type: string, enum: [image, video, carousel, text], nullable: true }
mediaItems:
type: array
description: All media items for this post. Carousel posts contain one entry per slide.
items:
type: object
properties:
type: { type: string, enum: [image, video] }
url: { type: string, format: uri, description: Direct URL to the media }
thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) }
AnalyticsListResponse:
type: object
properties:
overview:
$ref: '#/components/schemas/AnalyticsOverview'
posts:
type: array
items:
type: object
properties:
_id: { type: string }
latePostId: { type: string, nullable: true, 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, nullable: true }
thumbnailUrl: { type: string, format: uri }
mediaType: { type: string, enum: [image, video, gif, document, carousel, text] }
mediaItems:
type: array
description: All media items for this post. Carousel posts contain one entry per slide.
items:
type: object
properties:
type: { type: string, enum: [image, video] }
url: { type: string, format: uri, description: Direct URL to the media }
thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) }
pagination:
$ref: '#/components/schemas/Pagination'
accounts:
type: array
description: Connected social accounts (followerCount and followersLastUpdated only included if user has analytics add-on)
items:
$ref: '#/components/schemas/SocialAccount'
hasAnalyticsAccess:
type: boolean
description: Whether user has analytics add-on access
# LinkedIn Aggregate Analytics Responses
LinkedInAggregateAnalyticsTotalResponse:
type: object
description: Response for TOTAL aggregation (lifetime totals)
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: personal }
username: { type: string }
aggregation: { type: string, enum: [TOTAL] }
dateRange:
type: object
nullable: true
properties:
startDate: { type: string, format: date }
endDate: { type: string, format: date }
analytics:
type: object
properties:
impressions: { type: integer, description: Total impressions across all posts }
reach: { type: integer, description: Unique members reached across all posts }
reactions: { type: integer, description: Total reactions across all posts }
comments: { type: integer, description: Total comments across all posts }
shares: { type: integer, description: Total reshares across all posts }
saves: { type: integer, description: Total times posts were saved (personal accounts only) }
sends: { type: integer, description: Total times posts were sent via LinkedIn messaging (personal accounts only) }
engagementRate: { type: number, description: Overall engagement rate as percentage }
note: { type: string }
lastUpdated: { type: string, format: date-time }
LinkedInAggregateAnalyticsDailyResponse:
type: object
description: Response for DAILY aggregation (time series breakdown)
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: personal }
username: { type: string }
aggregation: { type: string, enum: [DAILY] }
dateRange:
type: object
nullable: true
properties:
startDate: { type: string, format: date }
endDate: { type: string, format: date }
analytics:
type: object
description: Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation.
properties:
impressions:
type: array
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
reactions:
type: array
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
comments:
type: array
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
shares:
type: array
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
saves:
type: array
description: Daily saves (personal accounts only)
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
sends:
type: array
description: Daily sends via LinkedIn messaging (personal accounts only)
items:
type: object
properties:
date: { type: string, format: date }
count: { type: integer }
skippedMetrics:
type: array
description: Metrics that were skipped due to API limitations
items: { type: string }
note: { type: string }
lastUpdated: { type: string, format: date-time }
# ============================================
# Response Schemas
# ============================================
# Posts Responses
PostsListResponse:
type: object
properties:
posts:
type: array
items:
$ref: '#/components/schemas/Post'
pagination:
$ref: '#/components/schemas/Pagination'
PostGetResponse:
type: object
properties:
post:
$ref: '#/components/schemas/Post'
PostCreateResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
PostUpdateResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
PostDeleteResponse:
type: object
properties:
message:
type: string
PostRetryResponse:
type: object
properties:
message:
type: string
post:
$ref: '#/components/schemas/Post'
# Profiles Responses
ProfilesListResponse:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/Profile'
ProfileGetResponse:
type: object
properties:
profile:
$ref: '#/components/schemas/Profile'
ProfileCreateResponse:
type: object
properties:
message:
type: string
profile:
$ref: '#/components/schemas/Profile'
ProfileUpdateResponse:
type: object
properties:
message:
type: string
profile:
$ref: '#/components/schemas/Profile'
ProfileDeleteResponse:
type: object
properties:
message:
type: string
# Accounts Responses
AccountsListResponse:
type: object
properties:
accounts:
type: array
items:
$ref: '#/components/schemas/SocialAccount'
hasAnalyticsAccess:
type: boolean
description: Whether user has analytics add-on access
AccountGetResponse:
type: object
properties:
account:
$ref: '#/components/schemas/SocialAccount'
FollowerStatsResponse:
type: object
properties:
accounts:
type: array
items:
$ref: '#/components/schemas/AccountWithFollowerStats'
dateRange:
type: object
properties:
from:
type: string
format: date-time
to:
type: string
format: date-time
aggregation:
type: string
enum: [daily, weekly, monthly]
# Media Responses
UploadedFile:
type: object
properties:
type:
type: string
enum: [image, video, document]
url:
type: string
format: uri
filename:
type: string
size:
type: integer
mimeType:
type: string
MediaUploadResponse:
type: object
properties:
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
UploadTokenResponse:
type: object
properties:
token:
type: string
uploadUrl:
type: string
format: uri
expiresAt:
type: string
format: date-time
status:
type: string
enum: [pending, completed, expired]
UploadTokenStatusResponse:
type: object
properties:
token:
type: string
status:
type: string
enum: [pending, completed, expired]
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
createdAt:
type: string
format: date-time
expiresAt:
type: string
format: date-time
completedAt:
type: string
format: date-time
# Queue Responses
QueueSlotsResponse:
type: object
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
QueueDeleteResponse:
type: object
properties:
success:
type: boolean
deleted:
type: boolean
QueuePreviewResponse:
type: object
properties:
profileId:
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
# Users Responses
User:
type: object
properties:
_id:
type: string
email:
type: string
name:
type: string
role:
type: string
createdAt:
type: string
format: date-time
UsersListResponse:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/User'
UserGetResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
AdMetrics:
type: object
properties:
spend: { type: number }
impressions: { type: integer }
reach: { type: integer }
clicks: { type: integer }
ctr: { type: number, description: Click-through rate (%) }
cpc: { type: number, description: Cost per click }
cpm: { type: number, description: Cost per 1000 impressions }
engagement: { type: integer }
conversions:
type: integer
description: "Count of conversion events matching the campaign's promoted_object.custom_event_type (PURCHASE, LEAD, etc.) over the requested date range. 0 for non-conversion campaigns or when no events have fired. Meta-only at time of writing; other platforms return 0."
costPerConversion:
type: number
description: "Derived spend / conversions in the same currency as spend. 0 when conversions is 0."
actions:
type: object
additionalProperties:
type: integer
description: "Raw per-action-type counts from Meta's Insights actions[] array, summed over the date range. Keys are Meta action_type strings (e.g. link_click, offsite_conversion.fb_pixel_purchase, onsite_conversion.lead_grouped). Use this to extract any conversion event (purchases, leads, add_to_cart, etc.) without relying on the derived conversions field. Empty object when no actions are reported."
example:
link_click: 160
post_engagement: 300
offsite_conversion.fb_pixel_purchase: 42
actionValues:
type: object
additionalProperties:
type: number
description: "Monetary mirror of `actions`, from Meta's Insights `action_values[]` array. Same keying — values are the revenue attributed to each action_type, in ad-account native currency (same unit as `spend`; see the campaign node's `currency` field). Use this to compute revenue-per-event (e.g. avg purchase value). Meta-only; other platforms return {}."
example:
offsite_conversion.fb_pixel_purchase: 2456.78
offsite_conversion.fb_pixel_add_to_cart: 980.50
purchaseValue:
type: number
description: "Convenience sum of purchase-type action values — picked from `actionValues` via the same priority list as `conversions` so both fields describe the same events. In ad-account native currency. 0 when the campaign has no purchase event configured. Meta-only."
roas:
type: number
description: "Return on ad spend — derived as `purchaseValue / spend`. 0 when `spend` is 0. Equivalent to Meta's `purchase_roas` under default attribution. At ad-set and campaign levels this is recomputed from summed purchaseValue + spend (NOT averaged across children) so it's mathematically correct at every rollup level."
lastSyncedAt: { type: string, format: date-time, description: "Present on individual ads only, not on campaign aggregations" }
AdStatus:
type: string
enum: [active, paused, pending_review, rejected, completed, cancelled, error]
BusinessCenter:
type: object
description: |
TikTok Business Center entity. Returned by `GET /v1/ads/business-centers`. BCs are
TikTok's agency container — one BC owns N advertisers (ad accounts). Most solo
advertisers don't have one; the agency token uses BCs to roll up multi-client access.
properties:
bcId:
type: string
description: Business Center ID
example: "7123456789012345678"
name:
type: string
description: Display name set by the BC owner
example: "Acme Agency"
advertiserCount:
type: integer
nullable: true
description: |
Number of advertisers reachable under this BC for the calling token.
`null` when the BC asset walk returned empty or failed (typical for
agency apps without full BC asset read scope) — distinct from `0`,
which would imply the BC genuinely has no advertisers.
example: 23
BidStrategy:
type: string
enum: [LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS]
description: |
Meta bid strategy. Same enum applies at campaign and ad-set level; ad-set value (when set)
overrides campaign-level. Cross-field rules:
- `LOWEST_COST_WITHOUT_CAP` (default): auto-bid, forbids `bidAmount` and `roasAverageFloor`.
- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: require `bidAmount` (whole currency units).
- `LOWEST_COST_WITH_MIN_ROAS`: requires `roasAverageFloor` (decimal multiplier, 2.0 = 2.0x).
Source: facebook-business-sdk-codegen api_specs/specs/enum_types.json (`AdSet_bid_strategy`,
`Campaign_bid_strategy`).
AdBudget:
type: object
description: Budget amount in the ad account's native currency (see the campaign's `currency` field for the code).
required: [amount, type]
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
Ad:
type: object
properties:
_id: { type: string }
name: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
status: { $ref: '#/components/schemas/AdStatus' }
adType: { type: string, enum: [boost, standalone] }
goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, 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." }
isExternal: { type: boolean, description: True for ads synced from platform ad managers }
budget:
type: object
nullable: true
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
metrics:
allOf:
- { $ref: '#/components/schemas/AdMetrics' }
nullable: true
platformAdId: { type: string }
platformAdAccountId: { type: string }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
campaignName: { type: string }
adSetName: { type: string }
platformObjective:
type: string
nullable: true
description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads."
example: OUTCOME_SALES
optimizationGoal:
type: string
nullable: true
description: "Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads."
example: OFFSITE_CONVERSIONS
platformAdAccountName:
type: string
nullable: true
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
format: date-time
nullable: true
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:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
nullable: true
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
nullable: true
description: |
Bid cap in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).
Populated when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. `null` for
auto-bid (`LOWEST_COST_WITHOUT_CAP`).
- Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here).
- TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`
(whichever is set on the ad group). TikTok stores all three in whole currency units.
Source: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).
example: 5
roasAverageFloor:
type: number
nullable: true
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
nullable: true
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
nullable: true
description: Platform-specific creative data. Fields vary by platform.
properties:
thumbnailUrl: { type: string, description: Primary thumbnail/image URL }
imageUrl: { type: string, description: Alternative image URL }
videoId: { type: string, nullable: true, 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, nullable: true, description: "Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads." }
objectType: { type: string, description: "Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available." }
mediaUrls:
type: array
items: { type: string }
description: All media URLs for this ad (carousel images, multiple assets). Populated for Meta (carousel child_attachments), Google Ads (responsive display marketing_images), and LinkedIn (multi-image posts).
body: { type: string, description: Ad copy/text }
googleHeadline: { type: string, description: Google Ads headline }
googleDescription: { type: string, description: Google Ads description }
linkUrl: { type: string, description: Destination URL }
pinterestImageUrl: { type: string }
pinterestTitle: { type: string }
pinterestDescription: { type: string }
targeting: { type: object }
schedule:
type: object
nullable: true
properties:
startDate: { type: string, format: date-time }
endDate: { type: string, format: date-time }
rejectionReason: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AdTreeAdSet:
type: object
description: Ad set (or ad group/line item depending on platform) with rolled-up metrics and child ads
properties:
platformAdSetId: { type: string }
adSetName: { type: string }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: Derived from child ad statuses }
adCount: { type: integer }
budget:
type: object
nullable: true
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
nullable: true
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, nullable: true, description: "Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)" }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
nullable: true
description: "Bid strategy for this ad set (overrides campaign level when set)"
bidAmount: { type: number, nullable: true, description: "Bid cap in whole currency units. Populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
roasAverageFloor: { type: number, nullable: true, description: "Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Populated when bidStrategy is LOWEST_COST_WITH_MIN_ROAS." }
promotedObject:
type: object
nullable: true
description: "Meta promoted object for this ad set (conversion event details)"
properties:
custom_event_type: { type: string }
pixel_id: { type: string }
page_id: { type: string }
ads:
type: array
items: { $ref: '#/components/schemas/Ad' }
description: Individual ads within this ad set (capped at 100). Returns a subset of Ad fields from the aggregation (core fields like _id, name, platform, status, budget, metrics, creative, goal are included; targeting and schedule may be absent).
AdTreeCampaign:
type: object
description: Campaign with nested ad sets and rolled-up metrics
properties:
platformCampaignId: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
campaignName: { type: string }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status derived from child ad statuses. Distinct from `reviewStatus`, which reflects the platform-side review state." }
reviewStatus:
type: string
nullable: true
enum: [in_review, approved, rejected, with_issues]
description: |
Platform-side review state of the campaign. Independent of the
children-derived delivery `status`: a campaign can have ads
already active (status=active) while the campaign itself is
still being reviewed by the platform (reviewStatus=in_review).
For Meta, derived from `effective_status` + `issues_info` on
the Campaign, plus ad-level PENDING_REVIEW rollup.
platformCampaignStatus:
type: string
nullable: true
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
nullable: true
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
nullable: true
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
nullable: true
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
nullable: true
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
nullable: true
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, nullable: true, description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
accountId: { type: string }
profileId: { type: string }
platformObjective: { type: string, nullable: true, description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
optimizationGoal:
type: string
nullable: true
description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
nullable: true
description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
bidAmount: { type: number, nullable: true, description: "Representative bid cap for the campaign — bubbled up from the top-spending ad set's `bid_amount` (whole currency units). Populated when the ad-set bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
roasAverageFloor: { type: number, nullable: true, description: "Representative ROAS floor for the campaign — bubbled up from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
promotedObject:
type: object
nullable: true
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' }
AdCampaign:
type: object
properties:
platformCampaignId: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
campaignName: { type: string }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status derived from child ad statuses. Distinct from `reviewStatus`." }
reviewStatus:
type: string
nullable: true
enum: [in_review, approved, rejected, with_issues]
description: "Platform-side review state of the campaign. See AdTreeCampaign.reviewStatus for the full description."
platformCampaignStatus:
type: string
nullable: true
description: "Raw platform-level campaign status (Meta `effective_status`)."
campaignIssuesInfo:
type: array
nullable: true
description: "Platform-reported campaign issues (Meta `issues_info[]`)."
items:
type: object
adCount: { type: integer }
budget:
type: object
nullable: true
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
nullable: true
description: Campaign-level budget (CBO). Null for ABO campaigns.
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
budgetLevel:
type: string
nullable: true
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
nullable: true
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, nullable: true, description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
accountId: { type: string }
profileId: { type: string }
platformObjective: { type: string, nullable: true, description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
optimizationGoal:
type: string
nullable: true
description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
nullable: true
description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
bidAmount: { type: number, nullable: true, description: "Representative bid cap from the top-spending ad set (whole currency units). Populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
roasAverageFloor: { type: number, nullable: true, description: "Representative ROAS floor from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
promotedObject:
type: object
nullable: true
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).
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 and
transactionId on Google.
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." }
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'." }
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. }
items:
type: array
description: Item-level detail for ecommerce events.
items:
type: object
properties:
id: { type: string }
name: { type: string }
price: { type: number }
quantity: { type: integer }
category: { type: string }
sourceUrl:
type: string
format: uri
description: URL where the conversion originated (used by Meta).
actionSource:
type: string
enum: [web, app, offline, crm, phone_call, system_generated]
description: Where the conversion happened. Used by Meta; Google ignores.
platformData:
type: object
additionalProperties: true
description: Escape hatch for platform-specific fields we haven't normalized. Forwarded as-is.
webhooks:
post.scheduled:
post:
operationId: onPostScheduled
summary: Post scheduled event
description: Fired when a post is created and scheduled for publishing.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.published:
post:
operationId: onPostPublished
summary: Post published event
description: Fired when a post is successfully published.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.failed:
post:
operationId: onPostFailed
summary: Post failed event
description: Fired when a post fails to publish on all target platforms.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.partial:
post:
operationId: onPostPartial
summary: Post partial event
description: Fired when a post publishes on some platforms and fails on others.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.cancelled:
post:
operationId: onPostCancelled
summary: Post cancelled event
description: Fired when a post publishing job is cancelled.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.recycled:
post:
operationId: onPostRecycled
summary: Post recycled event
description: Fired when a post is recycled (cloned and re-scheduled for publishing).
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
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
message.sent:
post:
operationId: onMessageSent
summary: Message sent event
description: Fired when a message is sent via the API.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageSent'
responses:
'200':
description: Webhook received successfully
message.edited:
post:
operationId: onMessageEdited
summary: Message edited event
description: |
Fired when a sender edits a previously-sent message. Supported on
Instagram, Facebook Messenger, and Telegram. The payload includes the
full editHistory so consumers can show prior versions.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageEdited'
responses:
'200':
description: Webhook received successfully
message.deleted:
post:
operationId: onMessageDeleted
summary: Message deleted event
description: |
Fired when a sender deletes (unsends) a message. Supported on Instagram
(incoming unsend) and WhatsApp (when the business deletes an outgoing
message via the Cloud API). The payload retains the pre-delete text
and attachments so API consumers can access the original content for
moderation or compliance — the Zernio dashboard UI hides it.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeleted'
responses:
'200':
description: Webhook received successfully
message.delivered:
post:
operationId: onMessageDelivered
summary: Message delivered event
description: |
Fired when an outgoing message is delivered to the recipient.
Supported on WhatsApp and Facebook Messenger.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
message.read:
post:
operationId: onMessageRead
summary: Message read event
description: |
Fired when an outgoing message is read by the recipient. Supported on
WhatsApp, Facebook Messenger, and Instagram.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
message.failed:
post:
operationId: onMessageFailed
summary: Message delivery failed event
description: |
Fired when an outgoing message fails to deliver. Currently only emitted
for WhatsApp (other platforms don't expose per-message failure via
webhook). The payload error object contains code, title, and
message from the platform.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
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
webhook.test:
post:
operationId: onWebhookTest
summary: Webhook test event
description: Fired when sending a test webhook to verify the endpoint configuration.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadTest'
responses:
'200':
description: Webhook received successfully
security:
- bearerAuth: []
paths:
# NOTE: Tools download endpoints (/v1/tools/{platform}/download, /transcript, /hashtag-checker) removed from docs but still functional for existing customers
# ============================================
# Validate
# ============================================
/v1/tools/validate/post-length:
post:
operationId: validatePostLength
tags: [Validate]
summary: Validate character count
description: |
Check weighted character count per platform and whether the text is within each platform's limit.
Twitter/X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length.
Returns counts and limits for all 15 supported platform variants.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
description: The post text to check
example: "Check out https://zernio.com for scheduling posts!"
responses:
"200":
description: Character counts per platform
content:
application/json:
schema:
type: object
properties:
text: { type: string }
platforms:
type: object
additionalProperties:
type: object
properties:
count: { type: integer, description: "Character count for this platform" }
limit: { type: integer, description: "Maximum allowed characters" }
valid: { type: boolean, description: "Whether the text is within the limit" }
example:
twitter: { count: 51, limit: 280, valid: true }
twitterPremium: { count: 51, limit: 25000, valid: true }
instagram: { count: 51, limit: 2200, valid: true }
bluesky: { count: 51, limit: 300, valid: true }
snapchat: { count: 51, limit: 160, valid: true }
/v1/tools/validate/post:
post:
operationId: validatePost
tags: [Validate]
summary: Validate post content
description: |
Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations.
Accepts the same body as POST /v1/posts. Does NOT validate accounts, process media, or track usage. This is content-only validation.
Returns errors for failures and warnings for near-limit content (>90% of character limit).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platforms]
properties:
content:
type: string
description: Post text content
example: "Check out this video!"
platforms:
type: array
description: Target platforms (same format as POST /v1/posts)
items:
type: object
required: [platform]
properties:
platform:
type: string
enum: [twitter, instagram, tiktok, youtube, facebook, linkedin, bluesky, threads, reddit, pinterest, telegram, snapchat, googlebusiness, discord]
customContent: { type: string }
platformSpecificData: { type: object }
customMedia:
type: array
items:
type: object
properties:
url: { type: string }
type: { type: string, enum: [image, video] }
example:
- platform: youtube
- platform: twitter
mediaItems:
type: array
description: Root media items shared across platforms
items:
type: object
properties:
url: { type: string, format: uri }
type: { type: string, enum: [image, video] }
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 }
/v1/tools/validate/media:
post:
operationId: validateMedia
tags: [Validate]
summary: Validate media URL
description: |
Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons.
Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection.
Platform limits are sourced from each platform's actual upload constraints.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: Public media URL to validate
example: "https://example.com/image.jpg"
responses:
"200":
description: Media validation result
content:
application/json:
schema:
type: object
properties:
valid: { type: boolean }
url: { type: string, format: uri }
error: { type: string, description: "Error message if valid is false" }
contentType: { type: string, example: "image/jpeg" }
size: { type: integer, nullable: true, 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 }
/v1/tools/validate/subreddit:
get:
operationId: validateSubreddit
tags: [Validate]
summary: Check subreddit existence
description: |
Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed).
When accountId is provided, uses authenticated Reddit OAuth API with automatic token refresh (recommended). Falls back to Reddit's public JSON API, which may be unreliable from server IPs. Returns exists: false for private, banned, or nonexistent subreddits.
security:
- bearerAuth: []
parameters:
- name: name
in: query
required: true
description: Subreddit name (with or without "r/" prefix)
schema:
type: string
example: "programming"
- name: accountId
in: query
description: Reddit social account ID for authenticated lookup (recommended for reliable results)
schema:
type: string
responses:
"200":
description: Subreddit lookup result
content:
application/json:
schema:
oneOf:
- type: object
description: Subreddit exists
properties:
exists: { type: boolean }
subreddit:
type: object
properties:
name: { type: string, example: "programming" }
title: { type: string, example: "programming" }
description: { type: string, example: "Computer Programming" }
subscribers: { type: integer, example: 6844284 }
isNSFW: { type: boolean }
type: { type: string, enum: [public, private, restricted], example: "public" }
allowImages: { type: boolean }
allowVideos: { type: boolean }
- type: object
description: Subreddit not found
properties:
exists: { type: boolean }
error: { type: string }
# ============================================
# Analytics
# ============================================
/v1/analytics:
get:
operationId: getAnalytics
tags: [Analytics]
summary: Get post analytics
description: |
Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats.
Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days.
Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats.
LinkedIn personal accounts: Analytics are only available for posts published through Zernio. LinkedIn's API only returns metrics for posts authored by the authenticated user. Organization/company page analytics work for all posts.
parameters:
- name: postId
in: query
schema: { type: string }
description: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics.
- name: platform
in: query
schema: { type: string }
description: Filter by platform (default "all")
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID (default "all")
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID
- name: source
in: query
schema: { type: string, enum: [all, late, external], default: all }
description: "Filter by post source: late (posted via Zernio API), external (synced from platform), all (default)"
- name: fromDate
in: query
schema: { type: string, format: date }
description: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days.
- name: toDate
in: query
schema: { type: string, format: date }
description: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted.
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
description: Page size (default 50)
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
description: Page number (default 1)
- name: sortBy
in: query
schema: { type: string, enum: [date, engagement, impressions, reach, likes, comments, shares, saves, clicks, views], default: date }
description: Sort by date, engagement, or a specific metric
- name: order
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order
responses:
'200':
description: Analytics result
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/AnalyticsSinglePostResponse'
- $ref: '#/components/schemas/AnalyticsListResponse'
examples:
singlePost:
summary: Single post analytics (Zernio post with synced analytics)
value:
postId: "65f1c0a9e2b5af0012ab34cd"
latePostId: null
status: "published"
content: "Check out our new product launch!"
scheduledFor: "2024-11-01T10:00:00Z"
publishedAt: "2024-11-01T10:00:05Z"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
platformAnalytics:
- platform: "twitter"
status: "published"
platformPostId: "123456789"
accountId: "64e1f0a9e2b5af0012ab34cd"
accountUsername: "@acmecorp"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
syncStatus: "synced"
platformPostUrl: "https://twitter.com/acmecorp/status/123456789"
errorMessage: null
platform: "twitter"
platformPostUrl: "https://twitter.com/acmecorp/status/123456789"
isExternal: false
syncStatus: "synced"
message: null
thumbnailUrl: "https://storage.example.com/image.jpg"
mediaType: "image"
mediaItems:
- type: "image"
url: "https://storage.example.com/image.jpg"
thumbnail: "https://storage.example.com/image.jpg"
postList:
summary: Paginated analytics list
description: |
Note: The list endpoint returns External Post IDs. Posts originally
scheduled via Zernio will have isExternal: true in this response.
Use platformPostUrl to correlate with your original Zernio Post IDs.
value:
overview:
totalPosts: 156
publishedPosts: 156
scheduledPosts: 0
lastSync: "2024-11-02T08:30:00Z"
dataStaleness:
staleAccountCount: 0
syncTriggered: false
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
latePostId: "65f1c0a9e2b5af0012ab34ab"
content: "Check out our new product launch!"
scheduledFor: "2024-11-01T10:00:00Z"
publishedAt: "2024-11-01T10:00:05Z"
status: "published"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
platforms:
- platform: "instagram"
status: "published"
platformPostId: "17902345678901234"
accountId: "64e1f0a9e2b5af0012ab34cd"
accountUsername: "@acmecorp"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
syncStatus: "synced"
platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
errorMessage: null
platform: "instagram"
platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
isExternal: true
profileId: "64e1f0a9e2b5af0012ab34cd"
thumbnailUrl: "https://storage.example.com/thumb.jpg"
mediaType: "carousel"
mediaItems:
- type: "image"
url: "https://storage.example.com/slide1.jpg"
thumbnail: "https://storage.example.com/slide1.jpg"
- type: "image"
url: "https://storage.example.com/slide2.jpg"
thumbnail: "https://storage.example.com/slide2.jpg"
pagination:
page: 1
limit: 50
total: 156
pages: 4
accounts:
- _id: "64e1f0..."
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corp"
isActive: true
hasAnalyticsAccess: true
'202':
description: Analytics are being synced from the platform (single post lookup only). The response body matches AnalyticsSinglePostResponse with syncStatus "pending" and a message.
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsSinglePostResponse'
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Invalid query parameters }
details: { type: object, description: 'Detailed validation errors' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Analytics add-on required }
code: { type: string, example: analytics_addon_required }
'404': { $ref: '#/components/responses/NotFound' }
'424':
description: Post failed to publish on all platforms. Analytics are unavailable. (single post lookup only)
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsSinglePostResponse'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/youtube/channel-insights:
get:
operationId: getYouTubeChannelInsights
tags: [Analytics]
summary: Get YouTube channel-level 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 add-on required
'404':
description: Account not found
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
/v1/analytics/linkedin/org-aggregate-analytics:
get:
operationId: getLinkedInOrgAggregateAnalytics
tags: [Analytics]
summary: Get LinkedIn organization page aggregate 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 add-on required
'403':
description: |
Platform error. The authenticated member lacks the required
ADMINISTRATOR role on the organization. LinkedIn enforces admin-only
access for all three org statistics endpoints. The error envelope is
type platform_error, and the raw LinkedIn error is echoed in the
platformError field.
'404':
description: Account not found
'412':
description: Missing LinkedIn organization analytics scopes (r_organization_social + r_organization_followers + r_organization_admin)
/v1/analytics/tiktok/account-insights:
get:
operationId: getTikTokAccountInsights
tags: [Analytics]
summary: Get TikTok account-level insights
description: |
Returns account-level TikTok insights from /v2/user/info/ (live) plus historical
time series joined from Zernio's daily snapshotter (AccountStats).
Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
defaults to last 30 days. Requires the Analytics add-on and the user.info.stats
scope on the account (412 if missing).
Scope intentionally narrow. TikTok's public API exposes only the four counter
metrics below. The deep metrics that live in TikTok Studio are NOT available on any
public TikTok API, even for Business accounts:
- profile_views
- account-level impressions / reach
- follower inflow / outflow breakdown
- video watch time, average watch time, full-watched rate
- impression_sources (FYP / Following / Hashtag / Search / Personal profile)
TikTok's Research API doesn't expose those fields either, and is restricted to
non-commercial academic use per TikTok's eligibility policy. There is no public
API workaround. Post-level metrics (views, likes, comments, shares per video) are
available via /v1/analytics?postId=... from TikTok's /v2/video/query/.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the TikTok account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to
"follower_count,likes_count,video_count,followers_gained,followers_lost".
Live from /v2/user/info/ (requires user.info.stats scope):
- follower_count (cumulative; time series joined from AccountStats)
- following_count (cumulative; time series joined from AccountStats.metadata)
- likes_count (cumulative; time series joined from AccountStats.metadata)
- video_count (cumulative; time series joined from AccountStats.metadata)
Zernio-synthesized:
- followers_gained (sum of positive daily follower deltas)
- followers_lost (sum of absolute negative daily deltas)
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns the latest cumulative counter value.
"time_series" returns daily values joined from AccountStats snapshots.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a TikTok account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
'404':
description: Account not found
'412':
description: Missing user.info.stats scope
/v1/analytics/youtube/daily-views:
get:
operationId: getYouTubeDailyViews
tags: [Analytics]
summary: Get YouTube daily views
description: |
Returns daily view counts for a YouTube video including views, watch time, and subscriber changes.
Requires yt-analytics.readonly scope (re-authorization may be needed). Data has a 2-3 day delay. Max 90 days, defaults to last 30 days.
parameters:
- name: videoId
in: query
required: true
schema: { type: string }
description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio account ID for the YouTube account
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: endDate
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency).
responses:
'200':
description: Daily views breakdown
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDailyViewsResponse'
examples:
success:
summary: Successful response with daily views
value:
success: true
videoId: "dQw4w9WgXcQ"
dateRange:
startDate: "2025-01-01"
endDate: "2025-01-12"
totalViews: 12345
dailyViews:
- date: "2025-01-12"
views: 1234
estimatedMinutesWatched: 567.5
averageViewDuration: 45.2
subscribersGained: 10
subscribersLost: 2
likes: 89
comments: 12
shares: 5
- date: "2025-01-11"
views: 987
estimatedMinutesWatched: 432.1
averageViewDuration: 43.8
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 add-on required
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/facebook/page-insights:
get:
operationId: getFacebookPageInsights
tags: [Analytics]
summary: Get Facebook Page insights
description: |
Returns page-level Facebook insights (media views, views, post engagements, video metrics,
follower counts). Response shape matches /v1/analytics/instagram/account-insights so the
same client handling works across platforms.
Metric names track the current (post-November 2025) Meta Graph API. The legacy
page_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated
by Meta on November 15, 2025 and are NOT accepted by this endpoint. Use the replacements
below. Because Meta did not provide direct adds/removes replacements, Zernio synthesizes
followers_gained / followers_lost from the daily follower snapshotter.
Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the connected Facebook Page.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to
"page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost".
Live Meta metrics (current names, post-Nov-2025):
- page_media_view (replaces deprecated page_impressions)
- page_views_total
- page_post_engagements
- page_video_views
- page_video_view_time
- page_follows (replaces deprecated page_fans)
Zernio-synthesized from daily follower snapshots (filling the Nov-2025 gap
left by the page_fan_adds / page_fan_removes deprecation):
- followers_gained
- followers_lost
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals only.
"time_series" returns daily values in the "values" array.
responses:
'200':
description: Page insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series with computed follower deltas
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-03-01", until: "2026-03-22" }
metricType: "time_series"
metrics:
page_media_view:
total: 125000
values:
- { date: "2026-03-01", value: 5400 }
- { date: "2026-03-02", value: 4820 }
followers_gained:
total: 142
values:
- { date: "2026-03-01", value: 7 }
- { date: "2026-03-02", value: 5 }
followers_lost:
total: 23
values:
- { date: "2026-03-01", value: 1 }
- { date: "2026-03-02", value: 0 }
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names."
'400':
description: |
Bad request. Common cases:
- Requested a deprecated metric (page_impressions, page_fans, page_fan_adds, page_fan_removes) - use current names instead
- Account has no Page selected (metadata.pageAccessToken missing)
- Invalid accountId / metrics / metricType / date range
- Account is not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
'404':
description: Account not found
/v1/analytics/instagram/account-insights:
get:
operationId: getInstagramAccountInsights
tags: [Analytics]
summary: Get Instagram insights
description: |
Returns account-level Instagram insights such as reach, views, accounts engaged, and total interactions.
These metrics reflect the entire account's performance across all content surfaces (feed, stories, explore, profile),
and are fundamentally different from post-level metrics. Data may be delayed up to 48 hours.
Max 90 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to "reach,views,accounts_engaged,total_interactions".
Valid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares,
replies, reposts, follows_and_unfollows, profile_links_taps.
Note: only "reach" supports metricType=time_series. All other metrics (including
follows_and_unfollows) are total_value only. This is an Instagram Graph API limitation,
not a Zernio limitation - the IG API does not return time-series data for these metrics.
For a daily running follower count, use /v1/analytics/instagram/follower-history instead.
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals and supports breakdowns.
"time_series" returns daily values but only works with the "reach" metric.
- name: breakdown
in: query
schema: { type: string }
description: |
Breakdown dimension (only valid with metricType=total_value).
Valid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series response with daily values
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "time_series"
metrics:
reach:
total: 12500
values:
- date: "2026-03-01"
value: 420
- date: "2026-03-02"
value: 385
views:
total: 45000
values:
- date: "2026-03-01"
value: 1520
- date: "2026-03-02"
value: 1380
dataDelay: "Data may be delayed up to 48 hours"
totalValueWithBreakdown:
summary: Total value response with media type breakdown
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "total_value"
breakdown: "media_product_type"
metrics:
reach:
total: 12500
breakdowns:
- dimension: "FEED"
value: 5000
- dimension: "REELS"
value: 7500
dataDelay: "Data may be delayed up to 48 hours"
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidMetric:
value:
error: "Invalid metrics: impressions"
validMetrics: ["accounts_engaged", "comments", "follows_and_unfollows", "likes", "profile_links_taps", "reach", "replies", "reposts", "saves", "shares", "total_interactions", "views"]
breakdownWithTimeSeries:
value:
error: "Breakdowns are only supported with metricType=total_value"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/instagram/follower-history:
get:
operationId: getInstagramFollowerHistory
tags: [Analytics]
summary: Get Instagram follower history
description: |
Returns a daily running Instagram follower count time series, served from Zernio's
cross-platform daily snapshotter. Exists because Meta removed follower_count from
the /insights endpoint in Graph API v22+ and never exposed a historical daily series
via any public API.
Response envelope matches /v1/analytics/instagram/account-insights so the same client
handling works. Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to "follower_count,followers_gained,followers_lost".
- follower_count : per-day raw follower count
- followers_gained : sum of positive daily deltas
- followers_lost : sum of absolute negative daily deltas
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns aggregated totals (latest for follower_count, sum for gained/lost).
"time_series" returns per-day values in the "values" array.
responses:
'200':
description: Follower history data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / date range, or account is not an Instagram account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
'404':
description: Account not found
/v1/analytics/instagram/demographics:
get:
operationId: getInstagramDemographics
tags: [Analytics]
summary: Get Instagram demographics
description: |
Returns audience demographic insights for an Instagram account, broken down by age, city, country, and/or gender.
Requires at least 100 followers. Returns top 45 entries per dimension.
Data may be delayed up to 48 hours. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metric
in: query
schema:
type: string
enum: [follower_demographics, engaged_audience_demographics]
default: follower_demographics
description: |
"follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers.
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, city, country, gender.
Defaults to all four if omitted.
- name: timeframe
in: query
schema:
type: string
enum: [this_week, this_month]
default: this_month
description: |
Time period for demographic data. Defaults to "this_month".
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramDemographicsResponse'
examples:
allBreakdowns:
summary: All four demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
metric: "follower_demographics"
timeframe: "last_30_days"
demographics:
age:
- dimension: "25-34"
value: 4500
- dimension: "18-24"
value: 3200
gender:
- dimension: "M"
value: 3000
- dimension: "F"
value: 4800
city:
- dimension: "New York, New York"
value: 800
- dimension: "Los Angeles, California"
value: 650
country:
- dimension: "US"
value: 5000
- dimension: "GB"
value: 1200
note: "Demographics show top 45 entries per dimension. Requires 100+ followers."
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidBreakdown:
value:
error: "Invalid breakdowns: location"
validBreakdowns: ["age", "city", "country", "gender"]
insufficientFollowers:
value:
success: false
error: "Demographic insights require at least 100 followers."
code: "instagram_insufficient_followers"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/youtube/demographics:
get:
operationId: getYouTubeDemographics
tags: [Analytics]
summary: Get YouTube demographics
description: |
Returns audience demographic insights for a YouTube channel, broken down by age, gender, and/or country.
Age and gender values are viewer percentages (0-100). Country values are view counts.
Data is based on signed-in viewers only, with a 2-3 day delay. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the YouTube account
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, gender, country.
Defaults to all three if omitted.
- name: startDate
in: query
schema: { type: string, format: date }
description: |
Start date in YYYY-MM-DD format. Defaults to 90 days ago.
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date in YYYY-MM-DD format. Defaults to 3 days ago (YouTube data latency).
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDemographicsResponse'
examples:
allBreakdowns:
summary: All three demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "youtube"
demographics:
age:
- dimension: "25-34"
value: 28.5
- dimension: "18-24"
value: 22.1
gender:
- dimension: "male"
value: 62.3
- dimension: "female"
value: 35.8
country:
- dimension: "US"
value: 12000
- dimension: "GB"
value: 3500
dateRange:
startDate: "2026-01-01"
endDate: "2026-03-31"
note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
'400':
description: Bad request (invalid parameters or not a YouTube account)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
'412':
description: YouTube Analytics scope not granted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
code: { type: string, example: "youtube_analytics_scope_missing" }
scopeStatus:
type: object
properties:
hasAnalyticsScope: { type: boolean, example: false }
requiresReauthorization: { type: boolean, example: true }
reauthorizeUrl: { type: string }
/v1/analytics/daily-metrics:
get:
operationId: getDailyMetrics
tags: [Analytics]
summary: Get daily aggregated metrics
description: |
Returns daily aggregated analytics metrics and a per-platform breakdown.
Each day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views).
Defaults to the last 180 days. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Inclusive start date (ISO 8601). Defaults to 180 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: Inclusive end date (ISO 8601). Defaults to now.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
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 add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
/v1/analytics/best-time:
get:
operationId: getBestTimeToPost
tags: [Analytics]
summary: Get best times to post
description: |
Returns the best times to post based on historical engagement data.
Groups all published posts by day of week and hour (UTC), calculating average engagement per slot.
Use this to auto-schedule posts at optimal times. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Best time slots
content:
application/json:
schema:
type: object
properties:
slots:
type: array
items:
type: object
properties:
day_of_week: { type: integer, description: "0=Monday, 6=Sunday", minimum: 0, maximum: 6 }
hour: { type: integer, description: "Hour in UTC (0-23)", minimum: 0, maximum: 23 }
avg_engagement: { type: number, description: "Average engagement (likes + comments + shares + saves)" }
post_count: { type: integer, description: "Number of posts in this slot" }
examples:
success:
value:
slots:
- day_of_week: 2
hour: 18
avg_engagement: 510.3
post_count: 15
- day_of_week: 0
hour: 9
avg_engagement: 342.5
post_count: 12
- day_of_week: 4
hour: 12
avg_engagement: 289.1
post_count: 8
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/content-decay:
get:
operationId: getContentDecay
tags: [Analytics]
summary: Get content performance decay
description: |
Returns how engagement accumulates over time after a post is published.
Each bucket shows what percentage of the post's total engagement had been reached by that time window.
Useful for understanding content lifespan (e.g. "posts reach 78% of total engagement within 24 hours").
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Content decay buckets
content:
application/json:
schema:
type: object
properties:
buckets:
type: array
items:
type: object
properties:
bucket_order: { type: integer, description: "Sort order (0 = earliest, 6 = latest)" }
bucket_label: { type: string, description: "Human-readable label" }
avg_pct_of_final: { type: number, description: "Average % of final engagement reached (0-100)" }
post_count: { type: integer, description: "Number of posts with data in this bucket" }
examples:
success:
value:
buckets:
- bucket_order: 0
bucket_label: "0-6h"
avg_pct_of_final: 45.2
post_count: 89
- bucket_order: 1
bucket_label: "6-12h"
avg_pct_of_final: 18.7
post_count: 89
- bucket_order: 2
bucket_label: "12-24h"
avg_pct_of_final: 14.1
post_count: 85
- bucket_order: 3
bucket_label: "1-2d"
avg_pct_of_final: 9.3
post_count: 82
- bucket_order: 4
bucket_label: "2-7d"
avg_pct_of_final: 8.1
post_count: 78
- bucket_order: 5
bucket_label: "7-30d"
avg_pct_of_final: 3.8
post_count: 64
- bucket_order: 6
bucket_label: "30d+"
avg_pct_of_final: 0.8
post_count: 41
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/posting-frequency:
get:
operationId: getPostingFrequency
tags: [Analytics]
summary: Get frequency vs engagement
description: |
Returns the correlation between posting frequency (posts per week) and engagement rate, broken down by platform.
Helps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination
with the average engagement rate observed across all weeks matching that frequency.
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by social account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Posting frequency data
content:
application/json:
schema:
type: object
properties:
frequency:
type: array
items:
type: object
properties:
platform: { type: string, example: "instagram" }
posts_per_week: { type: integer, description: "Number of posts published that week" }
avg_engagement_rate: { type: number, description: "Average engagement rate as percentage (0-100)" }
avg_engagement: { type: number, description: "Average raw engagement (likes+comments+shares+saves)" }
weeks_count: { type: integer, description: "Number of calendar weeks observed at this frequency" }
examples:
success:
value:
frequency:
- platform: "instagram"
posts_per_week: 2
avg_engagement_rate: 44.4
avg_engagement: 512
weeks_count: 18
- platform: "instagram"
posts_per_week: 4
avg_engagement_rate: 5.9
avg_engagement: 203
weeks_count: 6
- platform: "facebook"
posts_per_week: 3
avg_engagement_rate: 12.5
avg_engagement: 87
weeks_count: 10
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/post-timeline:
get:
operationId: getPostTimeline
tags: [Analytics]
summary: Get post analytics timeline
description: |
Returns a daily timeline of analytics metrics for a specific post, showing how impressions, likes,
and other metrics evolved day-by-day since publishing. Each row represents one day of data per platform.
For multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on.
parameters:
- name: postId
in: query
required: true
schema: { type: string }
description: |
The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID.
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Start of date range (ISO 8601). Defaults to 90 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: End of date range (ISO 8601). Defaults to now.
responses:
'200':
description: Daily analytics timeline
content:
application/json:
schema:
type: object
properties:
postId:
type: string
description: The postId that was requested
timeline:
type: array
items:
type: object
properties:
date: { type: string, format: date, description: "Date in YYYY-MM-DD format" }
platform: { type: string, description: "Platform name (e.g. instagram, tiktok)" }
platformPostId: { type: string, description: "Platform-specific post ID" }
impressions: { type: integer, description: "Total impressions on this date" }
reach: { type: integer, description: "Total reach on this date" }
likes: { type: integer, description: "Total likes on this date" }
comments: { type: integer, description: "Total comments on this date" }
shares: { type: integer, description: "Total shares on this date" }
saves: { type: integer, description: "Total saves on this date" }
clicks: { type: integer, description: "Total clicks on this date" }
views: { type: integer, description: "Total views on this date" }
examples:
single_platform:
summary: Single-platform post timeline
value:
postId: "6507a1b2c3d4e5f6a7b8c9d0"
timeline:
- date: "2025-01-15"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 1200
reach: 980
likes: 45
comments: 3
shares: 12
saves: 8
clicks: 25
views: 0
- date: "2025-01-16"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 2400
reach: 1850
likes: 92
comments: 7
shares: 21
saves: 15
clicks: 48
views: 0
'400':
description: Missing required postId parameter
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Missing required parameter: postId" }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Forbidden (post belongs to another user or API key scope violation)
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Forbidden" }
'404':
description: Post not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Post not found" }
/v1/analytics/googlebusiness/performance:
get:
operationId: getGoogleBusinessPerformance
tags: [Analytics]
summary: Get GBP performance metrics
description: |
Returns daily performance metrics for a Google Business Profile location.
Metrics include impressions (Maps/Search, desktop/mobile), website clicks,
call clicks, direction requests, conversations, bookings, and food orders.
Data may be delayed 2-3 days. Max 18 months of historical data.
Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated metric names. Defaults to all available metrics.
Valid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,
BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH,
BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS,
BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back.
- name: endDate
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
responses:
'200':
description: Performance metrics with daily time series
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
dateRange:
type: object
properties:
startDate: { type: string, format: date, example: "2026-03-01" }
endDate: { type: string, format: date, example: "2026-03-31" }
metrics:
type: object
description: Each key is a metric name containing total and daily values.
additionalProperties:
type: object
properties:
total: { type: integer, description: "Sum of all daily values in the range" }
values:
type: array
items:
type: object
properties:
date: { type: string, format: date }
value: { type: integer }
dataDelay: { type: string, example: "Data may be delayed 2-3 days" }
examples:
performance_data:
summary: Performance metrics for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
dateRange:
startDate: "2026-03-01"
endDate: "2026-03-31"
metrics:
WEBSITE_CLICKS:
total: 42
values:
- date: "2026-03-01"
value: 3
- date: "2026-03-02"
value: 1
CALL_CLICKS:
total: 7
values:
- date: "2026-03-01"
value: 1
BUSINESS_IMPRESSIONS_MOBILE_SEARCH:
total: 156
values:
- date: "2026-03-01"
value: 8
dataDelay: "Data may be delayed 2-3 days"
'400':
description: Invalid parameters
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Invalid metrics: INVALID_METRIC" }
validMetrics:
type: array
items: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
/v1/analytics/googlebusiness/search-keywords:
get:
operationId: getGoogleBusinessSearchKeywords
tags: [Analytics]
summary: Get GBP search keywords
description: |
Returns search keywords that triggered impressions for a Google Business Profile location.
Data is aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded.
Max 18 months of historical data. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: startMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: Start month (YYYY-MM). Defaults to 3 months ago.
- name: endMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: End month (YYYY-MM). Defaults to current month.
responses:
'200':
description: Search keywords with impression counts
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
monthRange:
type: object
properties:
startMonth: { type: string, example: "2026-01" }
endMonth: { type: string, example: "2026-03" }
keywords:
type: array
items:
type: object
properties:
keyword: { type: string, example: "restaurant near me" }
impressions: { type: integer, example: 245 }
note: { type: string, example: "Keywords below a minimum impression threshold are excluded by Google" }
examples:
keywords_data:
summary: Search keywords for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
monthRange:
startMonth: "2026-01"
endMonth: "2026-03"
keywords:
- keyword: "restaurant near me"
impressions: 245
- keyword: "best tapas barcelona"
impressions: 89
- keyword: "arbichat"
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 add-on required
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/account-groups:
get:
operationId: listAccountGroups
tags: [Account Groups]
summary: List groups
description: Returns all account groups for the authenticated user, including group names and associated account IDs.
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 }
examples:
example:
value:
groups:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- _id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Personal Brand"
accountIds:
- "64e1f0a9e2b5af0012ab34cf"
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createAccountGroup
tags: [Account Groups]
summary: Create group
description: Creates a new account group with a name and a list of social account IDs.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, accountIds]
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
properties:
_id: { type: string }
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
message: "Account group created successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Group name already exists }
/v1/account-groups/{groupId}:
put:
operationId: updateAccountGroup
tags: [Account Groups]
summary: Update group
description: Updates the name or account list of an existing group. You can rename the group, change its accounts, or both.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
example:
message: "Account group updated successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { description: Group name already exists }
delete:
operationId: deleteAccountGroup
tags: [Account Groups]
summary: Delete group
description: Permanently deletes an account group. The accounts themselves are not affected.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Account group deleted successfully"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/media/presign:
post:
operationId: getMediaPresignedUrl
tags: [Media]
summary: Get upload URL
description: Get a presigned URL to upload files directly to cloud storage (up to 5GB). Returns an uploadUrl and publicUrl. PUT your file to the uploadUrl, then use the publicUrl in your posts.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [filename, contentType]
properties:
filename:
type: string
description: Name of the file to upload
example: "my-video.mp4"
contentType:
type: string
description: MIME type of the file
enum:
- image/jpeg
- image/jpg
- image/png
- image/webp
- image/gif
- video/mp4
- video/mpeg
- video/quicktime
- video/avi
- video/x-msvideo
- video/webm
- video/x-m4v
- application/pdf
example: "video/mp4"
size:
type: integer
description: Optional file size in bytes for pre-validation (max 5GB)
example: 15234567
responses:
'200':
description: Presigned URL generated successfully
content:
application/json:
schema:
type: object
properties:
uploadUrl:
type: string
format: uri
description: Presigned URL to PUT your file to (expires in 1 hour)
publicUrl:
type: string
format: uri
description: Public URL where the file will be accessible after upload
key:
type: string
description: Storage key/path of the file
type:
type: string
enum: [image, video, document]
description: Detected file type based on content type
example:
uploadUrl: "<presigned-upload-url>"
publicUrl: "https://media.zernio.com/temp/1234567890_abc123_my-video.mp4"
key: "temp/1234567890_abc123_my-video.mp4"
type: "video"
'400':
description: Invalid request (missing filename, contentType, or unsupported content type)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
missing_filename:
value: { error: "filename and contentType are required" }
invalid_type:
value: { error: "Content type not allowed: text/plain" }
file_too_large:
value: { error: "File too large. Maximum size is 5GB." }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/reddit/search:
get:
operationId: searchReddit
tags: [Reddit Search]
summary: Search posts
description: Search Reddit posts using a connected account. Optionally scope to a specific subreddit.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: subreddit
in: query
schema: { type: string }
- name: q
in: query
required: true
schema: { type: string }
- name: restrict_sr
in: query
schema: { type: string, enum: ['0','1'] }
- name: sort
in: query
schema: { type: string, enum: [relevance, hot, top, new, comments], default: new }
- name: limit
in: query
schema: { type: integer, default: 25, maximum: 100 }
- name: after
in: query
schema: { type: string }
responses:
'200':
description: Search results
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/RedditPost'
after: { type: string, nullable: true }
before: { type: string, nullable: true }
example:
items:
- id: "1abc234"
fullname: "t3_1abc234"
title: "How to grow on social media in 2025"
selftext: "Here are my tips..."
author: "marketingpro"
subreddit: "socialmedia"
url: "https://www.reddit.com/r/socialmedia/comments/1abc234/"
permalink: "https://www.reddit.com/r/socialmedia/comments/1abc234/how_to_grow/"
score: 156
numComments: 42
createdUtc: 1730000000
over18: false
stickied: false
flairText: null
isGallery: false
after: "t3_1abc234"
before: null
'400': { description: Missing params }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/reddit/feed:
get:
operationId: getRedditFeed
tags: [Reddit Search]
summary: Get subreddit feed
description: Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: subreddit
in: query
schema: { type: string }
- name: sort
in: query
schema: { type: string, enum: [hot, new, top, rising], default: hot }
- name: limit
in: query
schema: { type: integer, default: 25, maximum: 100 }
- name: after
in: query
schema: { type: string }
- name: t
in: query
schema: { type: string, enum: [hour, day, week, month, year, all] }
responses:
'200':
description: Feed items
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/RedditPost'
after: { type: string, nullable: true }
before: { type: string, nullable: true }
example:
items:
- id: "1xyz789"
fullname: "t3_1xyz789"
title: "Top marketing trends this week"
author: "trendwatcher"
subreddit: "marketing"
url: "https://www.reddit.com/r/marketing/comments/1xyz789/"
permalink: "https://www.reddit.com/r/marketing/comments/1xyz789/top_marketing_trends/"
score: 892
numComments: 134
createdUtc: 1730100000
over18: false
stickied: false
flairText: null
isGallery: false
- id: "1def456"
fullname: "t3_1def456"
title: "Check out my grow setup"
author: "growthexpert"
subreddit: "gardening"
url: "https://www.reddit.com/gallery/1def456"
permalink: "https://www.reddit.com/r/gardening/comments/1def456/check_out_my_grow_setup/"
score: 567
numComments: 89
createdUtc: 1730050000
over18: false
stickied: false
flairText: null
isGallery: true
galleryImages:
- "https://i.redd.it/abc123.jpg"
- "https://i.redd.it/def456.jpg"
after: "t3_1def456"
before: null
'400': { description: Missing params }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/usage-stats:
get:
operationId: getUsageStats
tags: [Usage]
summary: Get plan and usage stats
description: Returns the current plan name, billing period, plan limits, and usage counts.
responses:
'200':
description: Usage stats
content:
application/json:
schema:
$ref: '#/components/schemas/UsageStats'
example:
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"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/posts:
get:
operationId: listPosts
tags: [Posts]
summary: List posts
description: Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform.
parameters:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/LimitParam'
- name: status
in: query
schema: { type: string, enum: [draft, scheduled, published, failed] }
- name: platform
in: query
schema: { type: string, example: twitter }
- name: profileId
in: query
schema: { type: string }
- name: createdBy
in: query
schema: { type: string }
- name: dateFrom
in: query
schema: { type: string, format: date }
- name: dateTo
in: query
schema: { type: string, format: date }
- name: includeHidden
in: query
schema: { type: boolean, default: false }
- name: search
in: query
schema: { type: string }
description: Search posts by text content.
- name: sortBy
in: query
schema:
type: string
enum: [scheduled-desc, scheduled-asc, created-desc, created-asc, status, platform]
default: scheduled-desc
description: Sort order for results.
- name: accountId
in: query
required: false
schema: { type: string }
description: Filter posts to those published via a specific social account (24-char hex ObjectId).
responses:
'200':
description: Paginated posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostsListResponse'
examples:
scheduledPost:
summary: Scheduled post (pending publish)
value:
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
tags: ["launch"]
createdAt: "2024-10-01T12:00:00Z"
updatedAt: "2024-10-01T12:00:00Z"
pagination:
page: 1
limit: 10
total: 1
pages: 1
publishedPost:
summary: Published post with platformPostUrl
value:
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
scheduledFor: "2024-11-01T10:00:00Z"
publishedAt: "2024-11-01T10:00:05Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
tags: ["launch"]
createdAt: "2024-10-01T12:00:00Z"
updatedAt: "2024-11-01T10:00:06Z"
pagination:
page: 1
limit: 10
total: 1
pages: 1
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createPost
tags: [Posts]
summary: Create post
description: |
Create and optionally publish a post. Immediate posts (publishNow: true) include platformPostUrl in the response.
Content is optional when media is attached or all platforms have customContent. See each platform's schema for media constraints.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: { type: string }
content:
type: string
description: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts.
mediaItems:
type: array
items:
type: object
properties:
type: { type: string, enum: [image, video, gif, document] }
url: { type: string, format: uri }
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
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:
type: object
properties:
type: { type: string, enum: [image, video, gif, document] }
url: { type: string, format: uri }
scheduledFor:
type: string
format: date-time
description: Optional per-platform scheduled time override. When omitted, the top-level scheduledFor is used.
platformSpecificData:
oneOf:
- $ref: '#/components/schemas/TwitterPlatformData'
- $ref: '#/components/schemas/ThreadsPlatformData'
- $ref: '#/components/schemas/FacebookPlatformData'
- $ref: '#/components/schemas/InstagramPlatformData'
- $ref: '#/components/schemas/LinkedInPlatformData'
- $ref: '#/components/schemas/PinterestPlatformData'
- $ref: '#/components/schemas/YouTubePlatformData'
- $ref: '#/components/schemas/GoogleBusinessPlatformData'
- $ref: '#/components/schemas/TikTokPlatformData'
- $ref: '#/components/schemas/TelegramPlatformData'
- $ref: '#/components/schemas/SnapchatPlatformData'
- $ref: '#/components/schemas/RedditPlatformData'
- $ref: '#/components/schemas/BlueskyPlatformData'
- $ref: '#/components/schemas/DiscordPlatformData'
scheduledFor: { type: string, format: date-time }
publishNow: { type: boolean, default: false }
isDraft:
type: boolean
default: false
description: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically.
timezone: { type: string, default: UTC }
tags:
type: array
description: "Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed."
items: { type: string }
hashtags:
type: array
items: { type: string }
mentions:
type: array
description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
items: { type: string }
crosspostingEnabled: { type: boolean, default: true }
metadata: { type: object, additionalProperties: true }
tiktokSettings:
$ref: '#/components/schemas/TikTokPlatformData'
description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
facebookSettings:
$ref: '#/components/schemas/FacebookPlatformData'
description: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
recycling:
$ref: '#/components/schemas/RecyclingConfig'
queuedFromProfile:
type: string
description: Profile ID to schedule via queue. When provided without scheduledFor, the post is auto-assigned to the next available slot. Do not call /v1/queue/next-slot and use that time in scheduledFor, as that bypasses queue locking.
queueId:
type: string
description: |
Specific queue ID to use when scheduling via queue.
Only used when queuedFromProfile is also provided.
If omitted, uses the profile's default queue.
examples:
facebookDraft:
summary: Facebook draft post (visible in Publishing Tools)
value:
content: "Draft post for review before publishing"
platforms:
- platform: facebook
accountId: "64e1f0a9e2b5af0012ab34cd"
publishNow: true
facebookSettings:
draft: true
recyclingPost:
summary: Post with weekly recycling and content variations
value:
content: "Check out our evergreen guide!"
platforms:
- platform: twitter
accountId: "64e1f0a9e2b5af0012ab34cd"
scheduledFor: "2025-06-01T10:00:00Z"
recycling:
gap: 2
gapFreq: week
expireCount: 6
contentVariations:
- "Check out our evergreen guide!"
- "Don't miss our essential guide!"
- "Our most popular guide, updated!"
tiktokPhotoCarousel:
summary: TikTok photo carousel (Creator Inbox draft)
description: |
Sends photos to TikTok Creator Inbox as a draft. The creator receives an inbox
notification and completes the post via TikTok's editing flow. Uses draft: true
which maps to TikTok API post_mode MEDIA_UPLOAD. Note: publish_type is not a
supported field; use draft instead.
value:
content: "Check out these photos!"
mediaItems:
- type: image
url: "https://example.com/photo1.jpg"
- type: image
url: "https://example.com/photo2.jpg"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
draft: true
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
photoCoverIndex: 0
autoAddMusic: false
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokPhotoDirect:
summary: TikTok photo carousel (direct publish)
description: |
Publishes photos directly to TikTok. With draft omitted or false, the post is
published immediately via TikTok API post_mode DIRECT_POST.
value:
content: "Check out these photos!"
mediaItems:
- type: image
url: "https://example.com/photo1.jpg"
- type: image
url: "https://example.com/photo2.jpg"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
photoCoverIndex: 0
autoAddMusic: false
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokVideo:
summary: TikTok video post (direct publish)
value:
content: "New video is live!"
mediaItems:
- type: video
url: "https://example.com/video.mp4"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
allowDuet: true
allowStitch: true
commercialContentType: "none"
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokVideoDraft:
summary: TikTok video post (Creator Inbox draft)
description: |
Sends a video to TikTok Creator Inbox as a draft. Video drafts use a dedicated
TikTok endpoint (/v2/post/publish/inbox/video/init/) that only accepts source_info,
so post_info fields (privacyLevel, allowComment, etc.) are set by the creator
during TikTok's editing flow.
value:
content: "New video draft!"
mediaItems:
- type: video
url: "https://example.com/video.mp4"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
draft: true
contentPreviewConfirmed: true
expressConsentGiven: true
multiPlatform:
summary: Multi-platform post (Twitter + LinkedIn)
value:
content: "We just launched our new product!"
mediaItems:
- type: image
url: "https://example.com/launch.jpg"
platforms:
- platform: twitter
accountId: "64e1f0a9e2b5af0012ab34cd"
- platform: linkedin
accountId: "64e1f0a9e2b5af0012ab34ef"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "America/New_York"
responses:
'201':
description: Post created
content:
application/json:
schema:
$ref: '#/components/schemas/PostCreateResponse'
examples:
scheduled:
summary: Scheduled post (URLs populated after publish time)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
message: "Post scheduled successfully"
immediatePublish:
summary: Immediate post with publishNow=true (URLs included)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
timezone: "UTC"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
message: "Post published successfully"
queueScheduled:
summary: Post scheduled via queue (using queuedFromProfile)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Scheduled via queue!"
status: "scheduled"
scheduledFor: "2024-11-01T09:00:00Z"
timezone: "America/New_York"
queuedFromProfile: "64f0a1b2c3d4e5f6a7b8c9d0"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
platforms:
- platform: "linkedin"
accountId:
_id: "64e1f0..."
platform: "linkedin"
username: "acme-corp"
displayName: "Acme Corp"
isActive: true
status: "pending"
message: "Post scheduled successfully"
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'409':
description: Duplicate content detected
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "This exact content was already posted to this account within the last 24 hours." }
details:
type: object
properties:
accountId: { type: string }
platform: { type: string }
existingPostId: { type: string }
'429':
description: "Rate limit exceeded. Possible causes: API rate limit, velocity limit (15 posts/hour per account), account cooldown, or daily platform limits."
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
description: Additional context about the rate limit
headers:
Retry-After:
description: Seconds until the rate limit resets (for API rate limits)
schema: { type: integer }
X-RateLimit-Limit:
description: The rate limit ceiling
schema: { type: integer }
X-RateLimit-Remaining:
description: Requests remaining in current window
schema: { type: integer }
/v1/posts/{postId}:
get:
operationId: getPost
tags: [Posts]
summary: Get post
description: |
Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Post
content:
application/json:
schema:
$ref: '#/components/schemas/PostGetResponse'
examples:
scheduledPost:
summary: Scheduled post (pending)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
publishedPost:
summary: Published post with platformPostUrl
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
failedPost:
summary: Failed post with error details
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "This post failed to publish"
status: "failed"
platforms:
- platform: "instagram"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "instagram"
username: "acmecorp"
isActive: false
status: "failed"
errorMessage: "Instagram access token has expired. Please reconnect your account."
errorCategory: "auth_expired"
errorSource: "user"
partialPost:
summary: Partial success (some platforms failed)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Launch announcement!"
status: "partial"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "threads"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "threads"
username: "acmecorp"
isActive: true
status: "failed"
errorMessage: "Post text exceeds the 500 character limit for Threads."
errorCategory: "user_content"
errorSource: "user"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
put:
operationId: updatePost
tags: [Posts]
summary: Update post
description: |
Update an existing post. Only draft, scheduled, failed, and partial posts can be edited.
Published, publishing, and cancelled posts cannot be modified.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
content: { type: string }
scheduledFor: { type: string, format: date-time }
tiktokSettings:
$ref: '#/components/schemas/TikTokPlatformData'
description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
facebookSettings:
$ref: '#/components/schemas/FacebookPlatformData'
description: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
recycling:
$ref: '#/components/schemas/RecyclingConfig'
additionalProperties: true
example:
content: "Updated content for our launch post!"
scheduledFor: "2024-11-02T14:00:00Z"
responses:
'200':
description: Post updated
content:
application/json:
schema:
$ref: '#/components/schemas/PostUpdateResponse'
example:
message: "Post updated successfully"
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Updated content for our launch post!"
status: "scheduled"
scheduledFor: "2024-11-02T14:00:00Z"
'207':
description: Partial publish success
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deletePost
tags: [Posts]
summary: Delete post
description: Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
$ref: '#/components/schemas/PostDeleteResponse'
example:
message: "Post deleted successfully"
'400':
description: Cannot delete published posts
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
/v1/posts/bulk-upload:
post:
operationId: bulkUploadPosts
tags: [Posts]
summary: Bulk upload from CSV
description: Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts.
parameters:
- name: dryRun
in: query
schema: { type: boolean, default: false }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'200':
description: Bulk upload results
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
totalRows: { type: integer }
created: { type: integer }
failed: { type: integer }
errors:
type: array
items:
type: object
properties:
row: { type: integer }
error: { type: string }
posts:
type: array
items: { $ref: '#/components/schemas/Post' }
example:
success: true
totalRows: 10
created: 8
failed: 2
errors:
- row: 3
error: "Invalid date format"
- row: 7
error: "Account not found"
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
content: "First bulk post"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
'207':
description: Partial success
'400':
description: Invalid CSV or validation errors
'401': { $ref: '#/components/responses/Unauthorized' }
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/retry:
post:
operationId: retryPost
tags: [Posts]
summary: Retry failed post
description: Immediately retries publishing a failed post. Returns the updated post with its new status.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Retry successful
content:
application/json:
schema:
$ref: '#/components/schemas/PostRetryResponse'
example:
message: "Post published successfully"
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Check out our new product!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "published"
platformPostId: "1234567890"
platformPostUrl: "https://twitter.com/acme/status/1234567890"
'207':
description: Partial success
'400':
description: Invalid state
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: Post is currently publishing
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (15 posts/hour per account), or account cooldown (temporarily rate-limited due to repeated errors).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/unpublish:
post:
operationId: unpublishPost
tags: [Posts]
summary: Unpublish post
description: |
Deletes a published post from the specified platform. The post record in Zernio is kept but its status is updated to cancelled.
Not supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to delete the post from
enum:
- threads
- facebook
- twitter
- linkedin
- youtube
- pinterest
- reddit
- bluesky
- googlebusiness
- telegram
example:
platform: "threads"
responses:
'200':
description: Post deleted from platform
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Post deleted from threads successfully"
'400':
description: "Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API deletion failed
/v1/posts/{postId}/edit:
post:
operationId: editPost
tags: [Posts]
summary: Edit published post
description: |
Edit a published post on a social media platform. Currently only supported for X (Twitter).
Requirements:
- Connected X account must have an active X Premium subscription
- Must be within 1 hour of original publish time
- Maximum 5 edits per tweet (enforced by X)
- Text-only edits (media changes are not supported)
The post record in Zernio is updated with the new content and edit history.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform, content]
properties:
platform:
type: string
description: The platform to edit the post on. Currently only twitter is supported.
enum: [twitter]
content:
type: string
description: The new tweet text content
example:
platform: "twitter"
content: "Updated tweet text with corrected information"
responses:
'200':
description: Post edited successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
id: { type: string, description: New tweet ID assigned by X after edit }
url: { type: string, format: uri, description: URL of the edited tweet }
message: { type: string }
example:
success: true
id: "1234567890123456790"
url: "https://twitter.com/i/web/status/1234567890123456790"
message: "Tweet edited successfully"
'400':
description: "Invalid request: platform not supported, post not published, edit window expired, not Premium, or missing content."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API edit failed
/v1/posts/{postId}/update-metadata:
post:
operationId: updatePostMetadata
tags: [Posts]
summary: Update post metadata
description: |
Updates metadata of a published video on the specified platform without re-uploading.
Currently only supported for YouTube. At least one updatable field is required.
Two modes:
1. Post-based (video published through Zernio): pass the Zernio postId in the URL and platform in the body.
2. Direct video ID (video uploaded outside Zernio, e.g. directly to YouTube): use _ as the postId,
and pass videoId + accountId + platform in the body. The accountId is the Zernio social account ID
for the connected YouTube channel.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
description: Zernio post ID, or "_" when using direct video ID mode
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to update metadata on
enum:
- youtube
videoId:
type: string
description: YouTube video ID (required for direct mode, ignored for post-based mode)
accountId:
type: string
description: Zernio social account ID (required for direct mode, ignored for post-based mode)
title:
type: string
maxLength: 100
description: New video title (max 100 characters for YouTube)
description:
type: string
description: New video description
tags:
type: array
items:
type: string
maxLength: 100
description: Array of keyword tags (max 500 characters combined for YouTube)
categoryId:
type: string
description: YouTube video category ID
privacyStatus:
type: string
enum: [public, private, unlisted]
description: Video privacy setting
thumbnailUrl:
type: string
format: uri
description: "Public URL of a custom thumbnail image (JPEG, PNG, or GIF, max 2 MB, recommended 1280x720). Works on any video you own, including existing videos not published through Zernio. The channel must be verified (phone verification) to set custom thumbnails."
madeForKids:
type: boolean
description: "COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting)."
containsSyntheticMedia:
type: boolean
description: "AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label."
playlistId:
type: string
description: "YouTube playlist ID to add the video to (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Only playlists owned by the channel are supported."
examples:
post-based:
summary: Update a video published through Zernio
value:
platform: "youtube"
title: "Updated Video Title"
description: "New SEO-optimized description"
tags: ["seo", "marketing", "tutorial"]
direct-video-id:
summary: Update a video uploaded directly to YouTube
value:
platform: "youtube"
videoId: "dQw4w9WgXcQ"
accountId: "68fb37418bbca9c10cbfef26"
title: "Updated Title with SEO Keywords"
tags: ["seo", "youtube", "optimization"]
update-thumbnail:
summary: Update thumbnail on an existing video
value:
platform: "youtube"
videoId: "dQw4w9WgXcQ"
accountId: "68fb37418bbca9c10cbfef26"
thumbnailUrl: "https://example.com/my-thumbnail.jpg"
responses:
'200':
description: Metadata updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
videoId: { type: string, description: Only present in direct video ID mode }
updatedFields:
type: array
items: { type: string }
example:
success: true
message: "YouTube video metadata updated successfully"
updatedFields: ["title", "description", "tags"]
'400':
description: "Invalid request: unsupported platform, post not published, missing fields, or validation error."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API update failed
/v1/users:
get:
operationId: listUsers
tags: [Users]
summary: List users
description: Returns all users in the workspace including roles and profile access. Also returns the currentUserId of the caller.
responses:
'200':
description: Users
content:
application/json:
schema:
type: object
properties:
currentUserId: { type: string }
users:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
email: { type: string }
role: { type: string }
isRoot: { type: boolean }
profileAccess:
type: array
items: { type: string }
createdAt: { type: string, format: date-time }
example:
currentUserId: "6507a1b2c3d4e5f6a7b8c9d0"
users:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "John Doe"
email: "john@example.com"
role: "owner"
isRoot: true
profileAccess: ["all"]
createdAt: "2024-01-15T10:30:00Z"
- _id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Jane Smith"
email: "jane@example.com"
role: "member"
isRoot: false
profileAccess:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
createdAt: "2024-03-20T14:45:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/users/{userId}:
get:
operationId: getUser
tags: [Users]
summary: Get user
description: Returns a single user's details by ID, including name, email, and role.
parameters:
- name: userId
in: path
required: true
schema: { type: string }
responses:
'200':
description: User
content:
application/json:
schema:
type: object
properties:
user:
type: object
properties:
_id: { type: string }
name: { type: string }
email: { type: string }
role: { type: string }
isRoot: { type: boolean }
profileAccess:
type: array
items: { type: string }
example:
user:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "John Doe"
email: "john@example.com"
role: "owner"
isRoot: true
profileAccess: ["all"]
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
/v1/profiles:
get:
operationId: listProfiles
tags: [Profiles]
summary: List profiles
description: Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit.
parameters:
- name: includeOverLimit
in: query
required: false
schema:
type: boolean
default: false
description: "When true, includes over-limit profiles (marked with isOverLimit: true)."
responses:
'200':
description: Profiles
content:
application/json:
schema:
$ref: '#/components/schemas/ProfilesListResponse'
examples:
example:
value:
profiles:
- _id: "64f0..."
name: "Personal Brand"
color: "#ffeda0"
isDefault: true
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createProfile
tags: [Profiles]
summary: Create profile
description: Creates a new profile with a name, optional description, and color.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
description: { type: string }
color: { type: string, example: '#ffeda0' }
example:
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileCreateResponse'
example:
message: "Profile created successfully"
profile:
_id: "64f0a1b2c3d4e5f6a7b8c9d0"
userId: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
isDefault: false
createdAt: "2024-11-01T10:00:00Z"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Profile limit exceeded }
/v1/profiles/{profileId}:
get:
operationId: getProfile
tags: [Profiles]
summary: Get profile
description: Returns a single profile by ID, including its name, color, and default status.
parameters:
- name: profileId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Profile
content:
application/json:
schema:
type: object
properties:
profile: { $ref: '#/components/schemas/Profile' }
example:
profile:
_id: "64f0a1b2c3d4e5f6a7b8c9d0"
userId: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Team"
description: "Profile for marketing campaigns"
color: "#4CAF50"
isDefault: false
createdAt: "2024-11-01T10:00:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
put:
operationId: updateProfile
tags: [Profiles]
summary: Update profile
description: Updates a profile's name, description, color, or default status.
parameters:
- name: profileId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
description: { type: string }
color: { type: string }
isDefault: { type: boolean }
example:
name: "Marketing Team (Updated)"
color: "#2196F3"
isDefault: true
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
profile: { $ref: '#/components/schemas/Profile' }
example:
message: "Profile updated successfully"
profile:
_id: "64f0a1b2c3d4e5f6a7b8c9d0"
userId: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Team (Updated)"
description: "Profile for marketing campaigns"
color: "#2196F3"
isDefault: true
createdAt: "2024-11-01T10:00:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteProfile
tags: [Profiles]
summary: Delete profile
description: Permanently deletes a profile by ID.
parameters:
- name: profileId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Profile deleted successfully"
'400': { description: Has connected accounts }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Forbidden }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts:
get:
operationId: listAccounts
tags: [Accounts]
summary: List accounts
description: |
Returns connected social accounts. Only includes accounts within the plan limit by default. Follower data requires analytics add-on.
Supports optional server-side pagination via page/limit params. When omitted, returns all accounts (backward-compatible).
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter accounts by profile ID
- name: platform
in: query
schema: { type: string }
description: Filter accounts by platform (e.g. "instagram", "twitter").
- name: includeOverLimit
in: query
required: false
schema:
type: boolean
default: false
description: When true, includes accounts from over-limit profiles.
- name: page
in: query
schema: { type: integer, minimum: 1 }
description: Page number (1-based). When provided with limit, enables server-side pagination. Omit for all accounts.
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100 }
description: Page size. Required alongside page for pagination.
responses:
'200':
description: Accounts (with optional pagination)
content:
application/json:
schema:
type: object
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'
examples:
example:
value:
accounts:
- _id: "64e1..."
platform: "twitter"
profileId:
_id: "64f0..."
name: "My Brand"
slug: "my-brand"
username: "@acme"
displayName: "Acme"
profileUrl: "https://x.com/acme"
isActive: true
hasAnalyticsAccess: false
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/follower-stats:
get:
operationId: getFollowerStats
tags: [Accounts, Analytics]
summary: Get follower stats
description: |
Returns follower count history and growth metrics for connected social accounts.
Requires analytics add-on subscription. Follower counts are refreshed once per day.
parameters:
- name: accountIds
in: query
schema: { type: string }
description: Comma-separated list of account IDs (optional, defaults to all user's accounts)
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: fromDate
in: query
schema: { type: string, format: date }
description: Start date in YYYY-MM-DD format (defaults to 30 days ago)
- name: toDate
in: query
schema: { type: string, format: date }
description: End date in YYYY-MM-DD format (defaults to today)
- name: granularity
in: query
schema: { type: string, enum: [daily, weekly, monthly], default: daily }
description: Data aggregation level
responses:
'200':
description: Follower stats
content:
application/json:
schema:
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 }
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 add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Analytics add-on required }
message: { type: string, example: Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature. }
requiresAddon: { type: boolean, example: true }
/v1/accounts/{accountId}:
put:
operationId: updateAccount
tags: [Accounts]
summary: Update account
description: Updates a connected social account's display name or username override.
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 }
example:
displayName: "Acme Corporation Official"
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
username: { type: string }
displayName: { type: string }
example:
message: "Account updated successfully"
username: "@acmecorp"
displayName: "Acme Corporation Official"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteAccount
tags: [Accounts]
summary: Disconnect account
description: Disconnects and removes a connected social account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Disconnected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Account disconnected successfully"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/health:
get:
operationId: getAllAccountsHealth
tags: [Accounts]
summary: Check accounts health
description: Returns health status of all connected accounts including token validity, permissions, and issues needing attention.
parameters:
- name: profileId
in: query
description: Filter by profile ID
schema: { type: string }
- name: platform
in: query
description: Filter by platform
schema:
type: string
enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord]
- name: status
in: query
description: Filter by health status
schema:
type: string
enum: [healthy, warning, error]
responses:
'200':
description: Account health summary
content:
application/json:
schema:
type: object
properties:
summary:
type: object
properties:
total: { type: integer, description: Total number of accounts }
healthy: { type: integer, description: Number of healthy accounts }
warning: { type: integer, description: Number of accounts with warnings }
error: { type: integer, description: Number of accounts with errors }
needsReconnect: { type: integer, description: Number of accounts needing reconnection }
accounts:
type: array
items:
type: object
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
profileId: { type: string }
status: { type: string, enum: [healthy, warning, error] }
canPost: { type: boolean }
canFetchAnalytics: { type: boolean }
tokenValid: { type: boolean }
tokenExpiresAt: { type: string, format: date-time }
needsReconnect: { type: boolean }
issues: { type: array, items: { type: string } }
example:
summary:
total: 5
healthy: 3
warning: 1
error: 1
needsReconnect: 1
accounts:
- accountId: "abc123"
platform: "instagram"
username: "myaccount"
status: "healthy"
canPost: true
canFetchAnalytics: true
tokenValid: true
tokenExpiresAt: "2025-06-15T00:00:00Z"
needsReconnect: false
issues: []
- accountId: "def456"
platform: "twitter"
username: "mytwitter"
status: "error"
canPost: false
canFetchAnalytics: false
tokenValid: false
needsReconnect: true
issues: ["Token expired"]
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/health:
get:
operationId: getAccountHealth
tags: [Accounts]
summary: Check account health
description: Returns detailed health info for a specific account including token status, permissions, and recommendations.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The account ID to check
responses:
'200':
description: Account health details
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
status:
type: string
enum: [healthy, warning, error]
description: Overall health status
tokenStatus:
type: object
properties:
valid: { type: boolean, description: Whether the token is valid }
expiresAt: { type: string, format: date-time }
expiresIn: { type: string, description: Human-readable time until expiry }
needsRefresh: { type: boolean, description: Whether token expires within 24 hours }
permissions:
type: object
properties:
posting:
type: array
items:
type: object
properties:
scope: { type: string }
granted: { type: boolean }
required: { type: boolean }
analytics:
type: array
items:
type: object
properties:
scope: { type: string }
granted: { type: boolean }
required: { type: boolean }
optional:
type: array
items:
type: object
properties:
scope: { type: string }
granted: { type: boolean }
required: { type: boolean }
canPost: { type: boolean }
canFetchAnalytics: { type: boolean }
missingRequired: { type: array, items: { type: string } }
issues:
type: array
items: { type: string }
description: List of issues found
recommendations:
type: array
items: { type: string }
description: Actionable recommendations to fix issues
example:
accountId: "abc123"
platform: "instagram"
username: "myaccount"
displayName: "My Account"
status: "healthy"
tokenStatus:
valid: true
expiresAt: "2025-06-15T00:00:00Z"
expiresIn: "180 days"
needsRefresh: false
permissions:
posting:
- scope: "instagram_basic"
granted: true
required: true
- scope: "instagram_content_publish"
granted: true
required: true
analytics:
- scope: "instagram_manage_insights"
granted: true
required: false
optional: []
canPost: true
canFetchAnalytics: true
missingRequired: []
issues: []
recommendations: []
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/tiktok/creator-info:
get:
operationId: getTikTokCreatorInfo
tags: [Accounts]
summary: Get TikTok creator info
description: Returns TikTok creator details, available privacy levels, posting limits, and commercial content options for a specific TikTok account. Only works with TikTok accounts.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The TikTok account ID
- name: mediaType
in: query
required: false
schema:
type: string
enum: [video, photo]
default: video
description: The media type to get creator info for (affects available interaction settings)
responses:
'200':
description: TikTok creator info and posting options
content:
application/json:
schema:
type: object
properties:
creator:
type: object
properties:
nickname: { type: string, description: Creator display name }
avatarUrl: { type: string, description: Creator avatar URL }
isVerified: { type: boolean, description: Whether the creator is verified }
canPostMore: { type: boolean, description: Whether the creator can publish more posts right now }
privacyLevels:
type: array
description: Available privacy level options for this creator
items:
type: object
properties:
value: { type: string, description: "Privacy level value to use when creating posts (e.g. PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)" }
label: { type: string, description: Human-readable label }
postingLimits:
type: object
properties:
maxVideoDurationSec: { type: integer, description: Maximum video duration in seconds }
interactionSettings:
type: object
description: Available interaction toggles (comment, duet, stitch) and their defaults
commercialContentTypes:
type: array
description: Available commercial content disclosure options
items:
type: object
properties:
value: { type: string }
label: { type: string }
requires:
type: array
items: { type: string }
example:
creator:
nickname: "myaccount"
avatarUrl: "https://example.com/avatar.jpg"
isVerified: false
canPostMore: true
privacyLevels:
- value: "PUBLIC_TO_EVERYONE"
label: "Public To Everyone"
- value: "MUTUAL_FOLLOW_FRIENDS"
label: "Mutual Follow Friends"
- value: "SELF_ONLY"
label: "Self Only"
postingLimits:
maxVideoDurationSec: 600
interactionSettings:
comment: true
duet: true
stitch: true
commercialContentTypes:
- value: "none"
label: "No Commercial Content"
- value: "brand_organic"
label: "Your Brand"
requires: ["is_brand_organic_post"]
'400':
description: Account is not a TikTok account
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "This endpoint is only available for TikTok accounts"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'429':
description: Creator has reached TikTok daily posting limit
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "TikTok creator has reached the daily posting limit. Please try again later."
/v1/api-keys:
get:
operationId: listApiKeys
tags: [API Keys]
summary: List keys
description: Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value.
responses:
'200':
description: API keys
content:
application/json:
schema:
type: object
properties:
apiKeys:
type: array
items: { $ref: '#/components/schemas/ApiKey' }
example:
apiKeys:
- id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Production API Key"
keyPreview: "sk_12345678...abcdef01"
expiresAt: "2025-12-31T23:59:59Z"
createdAt: "2024-01-15T10:30:00Z"
scope: "full"
profileIds: []
permission: "read-write"
- id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Analytics Read-Only"
keyPreview: "sk_87654321...12345678"
expiresAt: null
createdAt: "2024-03-20T14:45:00Z"
scope: "profiles"
profileIds:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Main Brand"
color: "#ffeda0"
permission: "read"
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createApiKey
tags: [API Keys]
summary: Create key
description: Creates a new API key with an optional expiry. The full key value is only returned once in the response.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
expiresIn:
type: integer
description: Days until expiry
scope:
type: string
enum: [full, profiles]
description: "'full' grants access to all profiles (default), 'profiles' restricts to specific profiles"
default: full
profileIds:
type: array
items: { type: string }
description: Profile IDs this key can access. Required when scope is 'profiles'.
permission:
type: string
enum: [read-write, read]
description: "'read-write' allows all operations (default), 'read' restricts to GET requests only"
default: read-write
example:
name: "Analytics Read-Only Key"
scope: "profiles"
profileIds: ["6507a1b2c3d4e5f6a7b8c9d0"]
permission: "read"
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
message: { type: string }
apiKey: { $ref: '#/components/schemas/ApiKey' }
example:
message: "API key created successfully"
apiKey:
id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Analytics Read-Only Key"
keyPreview: "sk_12345678...90abcdef"
key: "sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
expiresAt: null
createdAt: "2024-01-15T10:30:00Z"
scope: "profiles"
profileIds:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Main Brand"
color: "#ffeda0"
permission: "read"
'400': { description: Invalid request (missing name, invalid scope/permission, or missing profileIds when scope is 'profiles') }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/api-keys/{keyId}:
delete:
operationId: deleteApiKey
tags: [API Keys]
summary: Delete key
description: Permanently revokes and deletes an API key.
parameters:
- name: keyId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "API key deleted successfully"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/invite/tokens:
post:
operationId: createInviteToken
tags: [Invites]
summary: Create invite token
description: |
Generate a secure invite link to grant team members access to your profiles.
Invites expire after 7 days and are single-use.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scope]
properties:
scope:
type: string
enum: [all, profiles]
description: "'all' grants access to all profiles, 'profiles' restricts to specific profiles"
profileIds:
type: array
items: { type: string }
description: Required if scope is 'profiles'. Array of profile IDs to grant access to.
example:
scope: "profiles"
profileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
responses:
'201':
description: Invite token created
content:
application/json:
schema:
type: object
properties:
token: { type: string }
scope: { type: string }
invitedProfileIds:
type: array
items: { type: string }
expiresAt: { type: string, format: date-time }
inviteUrl: { type: string, format: uri }
example:
token: "inv_abc123def456ghi789"
scope: "profiles"
invitedProfileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
expiresAt: "2024-11-08T10:30:00Z"
inviteUrl: "https://zernio.com/invite/inv_abc123def456ghi789"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: One or more profiles not found or not owned }
/v1/connect/{platform}:
get:
operationId: getConnectUrl
tags: [Connect]
summary: Get OAuth connect URL
description: |
Initiate an OAuth connection flow. Returns an authUrl to redirect the user to.
Standard flow: Zernio hosts the selection UI, then redirects to your redirect_url. Headless mode (headless=true): user is redirected to your redirect_url with OAuth data for custom UI. Use the platform-specific selection endpoints to complete.
parameters:
- name: platform
in: path
required: true
schema:
type: string
enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, reddit, pinterest, bluesky, googlebusiness, telegram, snapchat, discord]
description: Social media platform to connect
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID (get from /v1/profiles)
- name: redirect_url
in: query
schema: { type: string, format: uri }
description: Your custom redirect URL after connection completes. Standard mode appends ?connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId.
- name: headless
in: query
schema: { type: boolean, default: false }
description: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.
security:
- bearerAuth: []
responses:
'200':
description: OAuth authorization URL to redirect user to
content:
application/json:
schema:
type: object
properties:
authUrl:
type: string
format: uri
description: URL to redirect your user to for OAuth authorization
state:
type: string
description: State parameter for security (handled automatically)
example:
authUrl: "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
state: "user123-profile456-1234567890-https://yourdomain.com/callback"
'400':
description: "Missing/invalid parameters (e.g., invalid profileId format)"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: "No access to profile, or BYOK required for AppSumo Twitter"
'404':
description: Profile not found
post:
operationId: handleOAuthCallback
tags: [Connect]
summary: Complete OAuth callback
description: Exchange the OAuth authorization code for tokens and connect the account to the specified profile.
parameters:
- name: platform
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code, state, profileId]
properties:
code: { type: string }
state: { type: string }
profileId: { type: string }
responses:
'200': { description: Account connected }
'400': { description: Invalid params }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: BYOK required for AppSumo Twitter }
'500': { description: Failed to connect account }
/v1/connect/{platform}/ads:
get:
operationId: connectAds
tags: [Connect]
summary: Connect ads for a platform
description: |
Unified ads connection endpoint. Creates a dedicated ads SocialAccount for the specified platform.
Same-token platforms (facebook, instagram, linkedin, pinterest): Creates an ads SocialAccount (metaads, linkedinads, pinterestads) with a copied OAuth token from the parent posting account. If the ads account already exists, returns alreadyConnected: true. No extra OAuth needed.
Separate-token platforms (tiktok, twitter): Starts the platform-specific marketing API OAuth flow and creates an ads SocialAccount (tiktokads, xads) with its own token. If the ads account already exists, returns alreadyConnected: true.
- tiktok: accountId is OPTIONAL. With accountId, the new tiktokads account links to that posting account (parentAccountId set) — Spark Ads + standalone ads using the posting TT_USER identity become available. Without accountId, ads-only mode kicks in: the new tiktokads account has parentAccountId=null and standalone ads use a synthetic CUSTOMIZED_USER ("Brand Identity"); Spark Ads are unavailable because TikTok requires a posting account for them. The Brand Identity is configured separately via PATCH /v1/connect/tiktok-ads (or inline on POST /v1/ads/create via the brandIdentity field).
- twitter (X Ads): accountId is REQUIRED. There's no ads-only mode — tweets need to be authored by a real X user.
Standalone platforms (googleads): Starts the Google Ads OAuth flow and creates a standalone ads SocialAccount (googleads) with no parent. If the account already exists, returns alreadyConnected: true.
Ads accounts appear as regular SocialAccount documents with ads platform values (e.g., metaads, tiktokads) in GET /v1/accounts.
parameters:
- name: platform
in: path
required: true
schema:
type: string
enum: [facebook, instagram, linkedin, tiktok, twitter, pinterest, googleads]
description: Platform to connect ads for. Only platforms with ads support are accepted.
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: accountId
in: query
schema: { type: string }
description: |
Existing SocialAccount ID. Required for `twitter` (X Ads). Optional for `tiktok` —
omit to enter ads-only mode (no TikTok posting account linked; ad creation uses
a Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`,
`instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) platforms.
- name: redirect_url
in: query
schema: { type: string, format: uri }
description: Custom redirect URL after OAuth completes (same-token platforms only)
- name: headless
in: query
schema: { type: boolean, default: false }
description: Enable headless mode (same-token platforms only)
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 }
- 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 add-on required, or no access to profile"
'404':
description: "Profile or posting account not found"
/v1/connect/tiktok-ads:
patch:
operationId: configureTikTokAdsBrandIdentity
tags: [Connect]
summary: Configure TikTok Ads Brand Identity
description: |
Set or update the Brand Identity (display name + avatar) for a
`tiktokads` SocialAccount. TikTok requires every ad to carry an
`identity_id + identity_type` pair. The Brand Identity is the
CUSTOMIZED_USER alternative to attributing ads to a real @username
(TT_USER). This route uploads the supplied image to TikTok, creates
the identity via `/v2/identity/create/`, and caches the resulting
`identity_id` on the account so subsequent `POST /v1/ads/create`
calls can opt into it via `identityType: 'CUSTOMIZED_USER'`.
Configurable on every `tiktokads` account, including linked-mode ones
(those with a posting account on the same profile). Configuration is
idempotent and harmless when posting is also connected: the default
ad-create path still prefers TT_USER, and CUSTOMIZED_USER is only used
per-ad when the caller explicitly opts in.
TikTok identities are immutable post-creation. Re-saving creates a new
identity on TikTok and swaps the cached id; the old identity stays
orphaned on TikTok's side (harmless, no billing impact).
Alternative: pass `brandIdentity` directly on `POST /v1/ads/create` to
configure on first ad creation in a single round-trip.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, displayName, imageUrl]
properties:
accountId:
type: string
description: SocialAccount ID of the `tiktokads` account.
displayName:
type: string
minLength: 1
maxLength: 40
description: Brand name shown above the ad on TikTok.
imageUrl:
type: string
format: uri
description: Public URL of a square brand image (≥98×98 px, JPG/PNG, max 5 MB). Used as the brand avatar on the ad.
responses:
'200':
description: Brand identity configured (or updated)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
identityId: { type: string, description: The TikTok-assigned identity_id, cached on the account. }
displayName: { type: string }
'400':
description: "Missing fields, invalid lengths, account is in linked mode, or no advertiser found on the account"
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: TikTok Ads account not found
'500':
description: Failed to create the TikTok identity (TikTok API error)
/v1/connect/facebook/select-page:
get:
operationId: listFacebookPages
tags: [Connect]
summary: List Facebook pages
description: Returns the list of Facebook Pages the user can manage after OAuth. Extract tempToken and userProfile from the OAuth redirect params and pass them here. Use the X-Connect-Token header if connecting via API key.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID from your connection flow
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Facebook access token from the OAuth callback redirect
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: List of Facebook Pages available for connection
content:
application/json:
schema:
type: object
properties:
pages:
type: array
items:
type: object
properties:
id: { type: string, description: Facebook Page ID }
name: { type: string, description: Page name }
username: { type: string, description: Page username/handle (may be null) }
access_token: { type: string, description: Page-specific access token }
category: { type: string, description: Page category }
tasks: { type: array, items: { type: string }, description: User permissions for this page }
example:
pages:
- id: "123456789"
name: "My Brand Page"
username: "mybrand"
access_token: "EAAxxxxx..."
category: "Brand"
tasks: ["MANAGE", "CREATE_CONTENT"]
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch pages (e.g., invalid token, insufficient permissions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
post:
operationId: selectFacebookPage
tags: [Connect]
summary: Select Facebook page
description: Complete the headless flow by saving the user's selected Facebook page. Pass the userProfile from the OAuth redirect and use X-Connect-Token if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, pageId, tempToken, userProfile]
properties:
profileId:
type: string
description: Profile ID from your connection flow
pageId:
type: string
description: The Facebook Page ID selected by the user
tempToken:
type: string
description: Temporary Facebook access token from OAuth
userProfile:
type: object
description: Decoded user profile object from the OAuth callback
properties:
id: { type: string }
name: { type: string }
profilePicture: { type: string }
redirect_url:
type: string
format: uri
description: Optional custom redirect URL to return to after selection
example:
profileId: "507f1f77bcf86cd799439011"
pageId: "123456789"
tempToken: "EAAxxxxx..."
userProfile:
id: "987654321"
name: "John Doe"
profilePicture: "https://..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Facebook Page connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if custom redirect_url was provided
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [facebook] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
selectedPageName: { type: string }
example:
message: "Facebook page connected successfully"
redirect_url: "https://yourdomain.com/integrations/callback?connected=facebook&profileId=507f1f77bcf86cd799439011&username=My+Brand+Page"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "facebook"
username: "mybrand"
displayName: "My Brand Page"
profilePicture: "https://..."
isActive: true
selectedPageName: "My Brand Page"
'400':
description: "Missing required fields (profileId, pageId, tempToken, or userProfile)"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: User does not have access to the specified profile
'404':
description: Selected page not found in available pages
'500':
description: Failed to save Facebook connection
/v1/connect/googlebusiness/locations:
get:
operationId: listGoogleBusinessLocations
tags: [Connect]
summary: List GBP locations
description: >
For headless flows. Returns the list of GBP locations the user can manage.
Use pendingDataToken (from the OAuth callback redirect) to list locations
without consuming the token, so it remains available for select-location.
Use X-Connect-Token header if connecting via API key.
parameters:
- name: profileId
in: query
required: false
schema: { type: string }
description: Profile ID from your connection flow. Required for auth validation when provided.
- name: pendingDataToken
in: query
required: false
schema: { type: string }
description: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required.
- name: tempToken
in: query
required: false
schema: { type: string }
description: Legacy. Direct Google access token. Use pendingDataToken instead when available.
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 }
example:
locations:
- id: "9281089117903930794"
name: "My Coffee Shop"
accountId: "accounts/113303573364907650416"
accountName: "My Business Account"
address: "123 Main St, City, Country"
category: "Coffee shop"
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch locations (e.g., invalid token, insufficient permissions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
/v1/connect/googlebusiness/select-location:
post:
operationId: selectGoogleBusinessLocation
tags: [Connect]
summary: Select GBP location
description: >
Complete the headless GBP flow by saving the user's selected location.
The pendingDataToken is returned in your redirect URL after OAuth completes
(step=select_location). Tokens and profile data are stored server-side,
so only the pendingDataToken is needed here. Use X-Connect-Token header
if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, locationId, pendingDataToken]
properties:
profileId:
type: string
description: Profile ID from your connection flow
locationId:
type: string
description: The Google Business location ID selected by the user
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"
pendingDataToken: "a1b2c3d4e5f6..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Google Business location connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if custom redirect_url was provided
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [googlebusiness] }
username: { type: string }
displayName: { type: string }
isActive: { type: boolean }
selectedLocationName: { type: string }
selectedLocationId: { type: string }
example:
message: "Google Business location connected successfully"
redirect_url: "https://yourdomain.com/integrations/callback?connected=googlebusiness&profileId=507f1f77bcf86cd799439011&username=My+Coffee+Shop"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "googlebusiness"
username: "My Coffee Shop"
displayName: "My Coffee Shop"
isActive: true
selectedLocationName: "My Coffee Shop"
selectedLocationId: "9281089117903930794"
'400':
description: "Missing required fields (profileId, locationId, or tempToken)"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: User does not have access to the specified profile
'404':
description: Selected location not found in available locations
'500':
description: Failed to save Google Business connection
/v1/accounts/{accountId}/gmb-reviews:
get:
operationId: getGoogleBusinessReviews
tags: [GMB Reviews]
summary: Get reviews
description: Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 50 }
description: Number of reviews to fetch per page (max 50)
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from previous response
security:
- bearerAuth: []
responses:
'200':
description: Reviews fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
reviews:
type: array
items:
type: object
properties:
id: { type: string, description: Review ID }
name: { type: string, description: Full resource name }
reviewer:
type: object
properties:
displayName: { type: string }
profilePhotoUrl: { type: string, nullable: true }
isAnonymous: { type: boolean }
rating: { type: integer, minimum: 1, maximum: 5, description: Numeric star rating }
starRating: { type: string, enum: [ONE, TWO, THREE, FOUR, FIVE], description: Google's string rating }
comment: { type: string, description: Review text }
createTime: { type: string, format: date-time }
updateTime: { type: string, format: date-time }
reviewReply:
type: object
nullable: true
properties:
comment: { type: string, description: Business owner reply }
updateTime: { type: string, format: date-time }
averageRating: { type: number, description: Overall average rating }
totalReviewCount: { type: integer, description: Total number of reviews }
nextPageToken: { type: string, nullable: true, description: Token for next page }
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "9281089117903930794"
reviews:
- id: "AIe9_BGx1234567890"
name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx1234567890"
reviewer:
displayName: "John Smith"
profilePhotoUrl: "https://lh3.googleusercontent.com/a/..."
isAnonymous: false
rating: 5
starRating: "FIVE"
comment: "Great service and friendly staff! Highly recommend."
createTime: "2024-01-15T10:30:00Z"
updateTime: "2024-01-15T10:30:00Z"
reviewReply:
comment: "Thank you for your kind words! We appreciate your support."
updateTime: "2024-01-16T08:00:00Z"
- id: "AIe9_BGx0987654321"
name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx0987654321"
reviewer:
displayName: "Anonymous"
profilePhotoUrl: null
isAnonymous: true
rating: 4
starRating: "FOUR"
comment: "Good experience overall."
createTime: "2024-01-10T14:20:00Z"
updateTime: "2024-01-10T14:20:00Z"
reviewReply: null
averageRating: 4.5
totalReviewCount: 125
nextPageToken: "CiAKHAoUMTIzNDU2Nzg5"
'400':
description: Invalid request - not a Google Business account or missing location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "This endpoint is only available for Google Business Profile accounts"
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "You do not have permission to access reviews for this location."
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to fetch reviews
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-food-menus:
get:
operationId: getGoogleBusinessFoodMenus
tags: [GMB Food Menus]
summary: Get food menus
description: Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
responses:
'200':
description: Food menus fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
name: { type: string, description: Resource name of the food menus }
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "9281089117903930794"
name: "accounts/123456789/locations/9281089117903930794/foodMenus"
menus:
- labels:
- displayName: "Lunch Menu"
description: "Available 11am-3pm"
languageCode: "en"
sections:
- labels:
- displayName: "Appetizers"
items:
- labels:
- displayName: "Caesar Salad"
description: "Romaine, parmesan, croutons"
attributes:
price:
currencyCode: "USD"
units: "12"
dietaryRestriction: ["VEGETARIAN"]
'400':
description: Invalid request - not a Google Business account or missing location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "This endpoint is only available for Google Business Profile accounts"
'401':
description: Unauthorized or token invalid
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Access token invalid. Please reconnect your Google Business Profile account."
code: "token_invalid"
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "You do not have permission to access food menus for this location."
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to fetch food menus
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
operationId: updateGoogleBusinessFoodMenus
tags: [GMB Food Menus]
summary: Update food menus
description: Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [menus]
properties:
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
description: Array of food menus to set
updateMask:
type: string
description: Field mask for partial updates (e.g. "menus")
example:
menus:
- labels:
- displayName: "Dinner Menu"
languageCode: "en"
sections:
- labels:
- displayName: "Mains"
items:
- labels:
- displayName: "Grilled Salmon"
description: "With seasonal vegetables"
attributes:
price:
currencyCode: "USD"
units: "24"
allergen: ["FISH"]
updateMask: "menus"
responses:
'200':
description: Food menus updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
name: { type: string }
menus:
type: array
items:
$ref: '#/components/schemas/FoodMenu'
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Request body must include a \"menus\" array"
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'403':
description: Permission denied for this location
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Failed to update food menus
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-location-details:
get:
operationId: getGoogleBusinessLocationDetails
tags: [GMB Location Details]
summary: Get location details
description: Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: readMask
in: query
required: false
schema: { type: string }
description: |
Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours.
`title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here.
Note: `location` is a derived response field, not a Google readMask value, passing it returns 400.
security:
- bearerAuth: []
responses:
'200':
description: Location details fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
location:
type: object
nullable: true
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, nullable: true, description: Business name as set in GBP }
placeId: { type: string, nullable: true, description: Google Maps Place ID for this location }
reviewUrl: { type: string, nullable: true, description: Public "write a review" URL Google generates for this place }
mapsUri: { type: string, nullable: true, description: Public Google Maps URL for this location }
isVerified: { type: boolean, description: True when the location has Voice of Merchant (verified + live on Google) }
title: { type: string, description: Business name }
regularHours:
type: object
properties:
periods:
type: array
items:
type: object
properties:
openDay: { type: string, enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] }
openTime: { type: string, description: "Opening time in HH:MM format" }
closeDay: { type: string }
closeTime: { type: string }
specialHours:
type: object
properties:
specialHourPeriods:
type: array
items:
type: object
properties:
startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
openTime: { type: string }
closeTime: { type: string }
closed: { type: boolean }
profile:
type: object
properties:
description: { type: string, description: Business description }
websiteUri: { type: string }
phoneNumbers:
type: object
properties:
primaryPhone: { type: string }
additionalPhones: { type: array, items: { type: string } }
categories:
type: object
description: "Business categories (returned when readMask includes 'categories')"
properties:
primaryCategory:
type: object
properties:
name: { type: string, description: "Category resource name" }
displayName: { type: string, description: "Human-readable category name" }
additionalCategories:
type: array
items:
type: object
properties:
name: { type: string }
displayName: { type: string }
serviceItems:
type: array
description: "Services offered (returned when readMask includes 'serviceItems')"
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
languageCode: { type: string }
price:
type: object
properties:
currencyCode: { type: string }
units: { type: string }
nanos: { type: integer }
example:
success: true
accountId: "64e1f0a9e2b5af0012ab34cd"
locationId: "9281089117903930794"
location:
name: "Joe's Pizza"
placeId: "ChIJExampleJoesPizzaPlaceId"
reviewUrl: "https://search.google.com/local/writereview?placeid=ChIJExampleJoesPizzaPlaceId"
mapsUri: "https://maps.google.com/maps?cid=1234567890123456789"
isVerified: true
title: "Joe's Pizza"
regularHours:
periods:
- openDay: "MONDAY"
openTime: "11:00"
closeDay: "MONDAY"
closeTime: "22:00"
- openDay: "TUESDAY"
openTime: "11:00"
closeDay: "TUESDAY"
closeTime: "22:00"
specialHours:
specialHourPeriods:
- startDate: { year: 2026, month: 12, day: 25 }
closed: true
profile:
description: "Authentic New York style pizza since 1985"
websiteUri: "https://joespizza.com"
categories:
primaryCategory:
name: "categories/gcid:pizza_restaurant"
displayName: "Pizza restaurant"
additionalCategories:
- name: "categories/gcid:italian_restaurant"
displayName: "Italian restaurant"
'400':
description: |
Invalid request. Most commonly raised when the readMask query
includes a value that is not a valid Google Business Information
field (e.g. `location`, which is a response-only derived field).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Request contains an invalid argument."
code: "gbp_bad_request"
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
operationId: updateGoogleBusinessLocationDetails
tags: [GMB Location Details]
summary: Update location details
description: |
Updates GBP location details. The updateMask field is required and specifies which fields to update.
This endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported.
Common fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [updateMask]
properties:
updateMask:
type: string
description: "Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported."
regularHours:
type: object
properties:
periods:
type: array
items:
type: object
properties:
openDay: { type: string }
openTime: { type: string }
closeDay: { type: string }
closeTime: { type: string }
specialHours:
type: object
properties:
specialHourPeriods:
type: array
items:
type: object
properties:
startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
openTime: { type: string }
closeTime: { type: string }
closed: { type: boolean }
profile:
type: object
properties:
description: { type: string }
websiteUri: { type: string }
phoneNumbers:
type: object
properties:
primaryPhone: { type: string }
additionalPhones: { type: array, items: { type: string } }
categories:
type: object
description: "Primary and additional business categories. Use updateMask='categories' to update."
properties:
primaryCategory:
type: object
properties:
name:
type: string
description: "Category resource name (e.g. 'categories/gcid:laundromat'). Use Google's Categories API to look up valid IDs."
additionalCategories:
type: array
items:
type: object
properties:
name:
type: string
description: "Category resource name (e.g. 'categories/gcid:dry_cleaner')"
serviceItems:
type: array
description: "Services offered by the business. Use updateMask='serviceItems' to update."
items:
type: object
properties:
structuredServiceItem:
type: object
description: "A predefined service from Google's service type catalog"
properties:
serviceTypeId:
type: string
description: "Service type ID from Google's catalog (e.g. 'job_type_id:plumbing_drain_repair')"
description:
type: string
description: "Optional description of the service"
freeFormServiceItem:
type: object
description: "A custom service not in Google's catalog"
properties:
category:
type: string
description: "Category resource name this service belongs to (e.g. 'categories/gcid:laundromat')"
label:
type: object
properties:
displayName:
type: string
description: "Service name as displayed to users"
languageCode:
type: string
description: "Language code (e.g. 'en')"
price:
type: object
description: "Optional price for the service"
properties:
currencyCode: { type: string, description: "ISO 4217 currency code (e.g. 'USD')" }
units: { type: string, description: "Whole units of the amount" }
nanos: { type: integer, description: "Nano units (10^-9) of the amount" }
examples:
updateHours:
summary: Update business hours
value:
updateMask: "regularHours,specialHours"
regularHours:
periods:
- openDay: "MONDAY"
openTime: "09:00"
closeDay: "MONDAY"
closeTime: "17:00"
- openDay: "SATURDAY"
openTime: "10:00"
closeDay: "SATURDAY"
closeTime: "14:00"
specialHours:
specialHourPeriods:
- startDate: { year: 2026, month: 12, day: 25 }
closed: true
- startDate: { year: 2026, month: 12, day: 31 }
openTime: "09:00"
closeTime: "15:00"
updateCategories:
summary: Update business categories
value:
updateMask: "categories"
categories:
primaryCategory:
name: "categories/gcid:laundromat"
additionalCategories:
- name: "categories/gcid:dry_cleaner"
- name: "categories/gcid:laundry_service"
updateServices:
summary: Update service items
value:
updateMask: "serviceItems"
serviceItems:
- structuredServiceItem:
serviceTypeId: "job_type_id:plumbing_drain_repair"
description: "Full drain cleaning and repair service"
- freeFormServiceItem:
category: "categories/gcid:laundromat"
label:
displayName: "Wash & Fold Service"
languageCode: "en"
price:
currencyCode: "USD"
units: "25"
responses:
'200':
description: Location updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
'400':
description: Invalid request or missing updateMask
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/gmb-media:
get:
operationId: listGoogleBusinessMedia
tags: [GMB Media]
summary: List media
description: |
Lists media items (photos) for a Google Business Profile location.
Returns photo URLs, descriptions, categories, and metadata.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, maximum: 100, default: 100 }
description: Number of items to return (max 100)
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from previous response
security:
- bearerAuth: []
responses:
'200':
description: Media items fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
mediaItems:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
mediaFormat: { type: string, enum: [PHOTO, VIDEO] }
sourceUrl: { type: string }
googleUrl: { type: string, description: Google-hosted URL }
thumbnailUrl: { type: string }
description: { type: string }
createTime: { type: string, format: date-time }
locationAssociation:
type: object
properties:
category: { type: string }
nextPageToken: { type: string }
totalMediaItemsCount: { type: integer }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
operationId: createGoogleBusinessMedia
tags: [GMB Media]
summary: Upload photo
description: |
Creates a media item (photo) for a location from a publicly accessible URL.
Categories determine where the photo appears: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sourceUrl]
properties:
sourceUrl: { type: string, description: Publicly accessible image URL }
mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO }
description: { type: string, description: Photo description }
category:
type: string
enum: [COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL]
description: Where the photo appears on the listing
example:
sourceUrl: "https://example.com/photos/restaurant-interior.jpg"
description: "Dining area with outdoor seating"
category: "INTERIOR"
responses:
'200':
description: Media created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
mediaFormat: { type: string }
googleUrl: { type: string }
'400':
description: Invalid request or unsupported media format
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
operationId: deleteGoogleBusinessMedia
tags: [GMB Media]
summary: Delete photo
description: Deletes a photo or media item from a GBP location.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: mediaId
in: query
required: true
schema: { type: string }
description: The media item ID to delete
security:
- bearerAuth: []
responses:
'200':
description: Media deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: boolean }
mediaId: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-attributes:
get:
operationId: getGoogleBusinessAttributes
tags: [GMB Attributes]
summary: Get attributes
description: Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
responses:
'200':
description: Attributes fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
attributes:
type: array
items:
type: object
properties:
name: { type: string, description: "Attribute identifier (e.g. has_delivery)" }
valueType: { type: string, description: "Value type (BOOL, ENUM, URL, REPEATED_ENUM)" }
values: { type: array, items: {} }
repeatedEnumValue:
type: object
properties:
setValues: { type: array, items: { type: string } }
unsetValues: { type: array, items: { type: string } }
example:
success: true
attributes:
- name: "has_delivery"
valueType: "BOOL"
values: [true]
- name: "has_takeout"
valueType: "BOOL"
values: [true]
- name: "has_outdoor_seating"
valueType: "BOOL"
values: [true]
- name: "pay_credit_card_types_accepted"
valueType: "REPEATED_ENUM"
repeatedEnumValue:
setValues: ["visa", "mastercard", "amex"]
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
operationId: updateGoogleBusinessAttributes
tags: [GMB Attributes]
summary: Update attributes
description: |
Updates location attributes (amenities, services, etc.).
The attributeMask specifies which attributes to update (comma-separated).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [attributes, attributeMask]
properties:
attributes:
type: array
items:
type: object
properties:
name: { type: string }
values: { type: array, items: {} }
repeatedEnumValue:
type: object
properties:
setValues: { type: array, items: { type: string } }
unsetValues: { type: array, items: { type: string } }
attributeMask:
type: string
description: "Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout')"
example:
attributes:
- name: "has_delivery"
values: [true]
- name: "has_takeout"
values: [true]
- name: "has_outdoor_seating"
values: [false]
attributeMask: "has_delivery,has_takeout,has_outdoor_seating"
responses:
'200':
description: Attributes updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
attributes: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-place-actions:
get:
operationId: listGoogleBusinessPlaceActions
tags: [GMB Place Actions]
summary: List action links
description: |
Lists place action links for a Google Business Profile location.
Place actions are the booking, ordering, and reservation buttons that appear on your listing.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, maximum: 100, default: 100 }
- name: pageToken
in: query
schema: { type: string }
security:
- bearerAuth: []
responses:
'200':
description: Place actions fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
placeActionLinks:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
uri: { type: string, description: Action URL }
placeActionType: { type: string }
createTime: { type: string, format: date-time }
updateTime: { type: string, format: date-time }
nextPageToken: { type: string }
example:
success: true
placeActionLinks:
- name: "locations/123/placeActionLinks/456"
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
- name: "locations/123/placeActionLinks/789"
uri: "https://www.opentable.com/joespizza"
placeActionType: "DINING_RESERVATION"
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
operationId: createGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Create action link
description: |
Creates a place action link for a location.
Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [uri, placeActionType]
properties:
uri: { type: string, description: The action URL }
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: Type of action
example:
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
responses:
'200':
description: Place action created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string, description: Resource name of the created link }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
operationId: deleteGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Delete action link
description: Deletes a place action link (e.g. booking or ordering URL) from a GBP location.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: name
in: query
required: true
schema: { type: string }
description: "The resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
security:
- bearerAuth: []
responses:
'200':
description: Place action deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: boolean }
name: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
patch:
operationId: updateGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Update action link
description: |
Updates a place action link (change URL or action type).
Only the fields included in the request body will be updated.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
description: "Resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
uri:
type: string
description: New action URL
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: New action type
example:
name: "locations/123/placeActionLinks/456"
uri: "https://order.doordash.com/joespizza"
responses:
'200':
description: Place action updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-services:
get:
operationId: getGoogleBusinessServices
tags: [GMB Services]
summary: Get services
description: |
Gets the services offered by a Google Business Profile location.
Returns an array of service items (structured or free-form with optional price).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location.
security:
- bearerAuth: []
responses:
'200':
description: Services fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
services:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
description: { type: string }
price:
type: object
properties:
currencyCode: { type: string, example: "USD" }
units: { type: string, example: "50" }
nanos: { type: integer }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
operationId: updateGoogleBusinessServices
tags: [GMB Services]
summary: Replace services
description: |
Replaces the entire service list for a location.
Google's API requires full replacement; individual item updates are not supported.
Each service can be structured (using a predefined serviceTypeId) or free-form (custom label).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [serviceItems]
properties:
serviceItems:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
description: { type: string }
price:
type: object
properties:
currencyCode: { type: string }
units: { type: string }
nanos: { type: integer }
example:
serviceItems:
- freeFormServiceItem:
category: "categories/gcid:plumber"
label: { displayName: "Pipe Repair", description: "Emergency and scheduled pipe repair" }
price: { currencyCode: "USD", units: "150" }
responses:
'200':
description: Services updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
services: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-reviews/batch:
post:
operationId: batchGetGoogleBusinessReviews
tags: [GMB Reviews]
summary: Batch get reviews
description: |
Fetches reviews across multiple locations in a single request.
More efficient than calling GET /gmb-reviews per location for multi-location businesses.
Reviews are grouped by location in the response.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [locationNames]
properties:
locationNames:
type: array
items: { type: string }
description: "Array of full location resource names (e.g. ['accounts/123/locations/456'])"
pageSize:
type: integer
maximum: 50
default: 50
description: Number of reviews per page (max 50)
pageToken:
type: string
description: Pagination token from previous response
example:
locationNames: ["accounts/123/locations/456", "accounts/123/locations/789"]
pageSize: 50
responses:
'200':
description: Batch reviews fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationReviews:
type: array
items:
type: object
properties:
locationName: { type: string }
reviews: { type: array, items: { type: object } }
averageRating: { type: number }
totalReviewCount: { type: integer }
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/connect/pending-data:
get:
operationId: getPendingOAuthData
tags: [Connect]
summary: Get pending OAuth data
description: Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL. One-time use, expires after 10 minutes. No authentication required.
parameters:
- name: token
in: query
required: true
schema: { type: string }
description: The pending data token from the OAuth redirect URL (pendingDataToken parameter)
responses:
'200':
description: OAuth data fetched successfully
content:
application/json:
schema:
type: object
properties:
platform:
type: string
description: The platform (e.g., "linkedin")
profileId:
type: string
description: The Zernio profile ID
tempToken:
type: string
description: Temporary access token for the platform
refreshToken:
type: string
description: Refresh token (if available)
expiresIn:
type: number
description: Token expiry in seconds
userProfile:
type: object
description: User profile data (id, username, displayName, profilePicture)
selectionType:
type: string
enum: [organizations, pages, boards, locations, profiles]
description: Type of selection data
organizations:
type: array
description: LinkedIn organizations (when selectionType is "organizations")
items:
type: object
properties:
id: { type: string }
urn: { type: string }
name: { type: string }
vanityName: { type: string }
example:
platform: "linkedin"
profileId: "abc123"
tempToken: "AQV..."
refreshToken: "AQW..."
expiresIn: 5183999
userProfile:
id: "ABC123"
username: "John Doe"
displayName: "John Doe"
profilePicture: "https://..."
selectionType: "organizations"
organizations:
- id: "12345"
urn: "urn:li:organization:12345"
name: "Acme Corp"
vanityName: "acme-corp"
- id: "67890"
urn: "urn:li:organization:67890"
name: "Example Inc"
vanityName: "example-inc"
'400':
description: Missing token parameter
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404':
description: Token not found or expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/connect/linkedin/organizations:
get:
operationId: listLinkedInOrganizations
tags: [Connect]
summary: List LinkedIn orgs
description: Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth.
parameters:
- name: tempToken
in: query
required: true
schema: { type: string }
description: The temporary LinkedIn access token from the OAuth redirect
- name: orgIds
in: query
required: true
schema: { type: string }
description: Comma-separated list of organization IDs to fetch details for (max 100)
example: "12345678,87654321,11111111"
responses:
'200':
description: Organization details fetched successfully
content:
application/json:
schema:
type: object
properties:
organizations:
type: array
items:
type: object
properties:
id: { type: string, description: Organization ID }
logoUrl: { type: string, format: uri, description: Logo URL (may be absent if no logo) }
vanityName: { type: string, description: Organization's vanity name/slug }
website: { type: string, format: uri, description: Organization's website URL }
industry: { type: string, description: Organization's primary industry }
description: { type: string, description: Organization's description }
example:
organizations:
- id: "12345678"
logoUrl: "https://media.licdn.com/dms/image/v2/..."
vanityName: "acme-corp"
website: "https://acme.com"
industry: "Technology"
description: "Leading provider of innovative solutions"
- id: "87654321"
logoUrl: "https://media.licdn.com/dms/image/v2/..."
vanityName: "example-inc"
website: "https://example.com"
- id: "11111111"
'400':
description: Missing required parameters or too many organization IDs
content:
application/json:
schema:
type: object
properties:
error: { type: string }
example:
error: "Missing tempToken parameter"
'500':
description: Failed to fetch organization details
/v1/connect/linkedin/select-organization:
post:
operationId: selectLinkedInOrganization
tags: [Connect]
summary: Select LinkedIn org
description: Complete the LinkedIn connection flow. Set accountType to "personal" or "organization" to connect as a company page. Use X-Connect-Token if connecting via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, tempToken, userProfile, accountType]
properties:
profileId: { type: string }
tempToken: { type: string }
userProfile: { type: object }
accountType: { type: string, enum: [personal, organization] }
selectedOrganization: { type: object }
redirect_url: { type: string, format: uri }
examples:
personalAccount:
summary: Connect as personal LinkedIn profile
description: For personal accounts, set accountType to "personal" and omit selectedOrganization
value:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
tempToken: "AQX..."
userProfile:
id: "abc123"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/dms/image/v2/..."
accountType: "personal"
organizationAccount:
summary: Connect as org/company page
description: For organization pages, include the selectedOrganization object
value:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
tempToken: "AQX..."
userProfile:
id: "abc123"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/dms/image/v2/..."
accountType: "organization"
selectedOrganization:
id: "12345678"
urn: "urn:li:organization:12345678"
name: "Acme Corporation"
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: LinkedIn account connected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: The redirect URL with connection params appended (only if redirect_url was provided in request)
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [linkedin] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
accountType: { type: string, enum: [personal, organization] }
bulkRefresh:
type: object
properties:
updatedCount: { type: integer }
errors: { type: integer }
examples:
personalAccountResponse:
summary: Personal account connected
value:
message: "LinkedIn account connected successfully"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "linkedin"
username: "johndoe"
displayName: "John Doe"
profilePicture: "https://media.licdn.com/..."
isActive: true
accountType: "personal"
organizationWithRedirect:
summary: Org account with redirect URL
value:
message: "LinkedIn account connected successfully"
redirect_url: "https://yourapp.com/callback?connected=linkedin&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=Acme+Corporation"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "linkedin"
username: "acme-corp"
displayName: "Acme Corporation"
profilePicture: "https://media.licdn.com/..."
isActive: true
accountType: "organization"
bulkRefresh:
updatedCount: 5
errors: 0
'400': { description: Missing required fields }
'401': { $ref: '#/components/responses/Unauthorized' }
'500': { description: Failed to connect LinkedIn account }
/v1/connect/pinterest/select-board:
get:
operationId: listPinterestBoardsForSelection
tags: [Connect]
summary: List Pinterest boards
description: For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL.
parameters:
- name: X-Connect-Token
in: header
required: true
schema: { type: string }
description: Short-lived connect token from the OAuth redirect
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Pinterest access token from the OAuth callback redirect
responses:
'200':
description: List of Pinterest Boards available for connection
content:
application/json:
schema:
type: object
properties:
boards:
type: array
items:
type: object
properties:
id: { type: string, description: Pinterest Board ID }
name: { type: string, description: Board name }
description: { type: string, description: Board description }
privacy: { type: string, description: Board privacy setting }
example:
boards:
- id: "123456789012345678"
name: "Marketing Ideas"
description: "Collection of marketing inspiration"
privacy: "PUBLIC"
- id: "234567890123456789"
name: "Product Photos"
description: "Product photography"
privacy: "PUBLIC"
'400': { description: Missing required parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to profile }
'500': { description: Failed to fetch boards }
post:
operationId: selectPinterestBoard
tags: [Connect]
summary: Select Pinterest board
description: |
Complete the Pinterest connection flow. After OAuth, use this endpoint to save the selected board and complete the account connection. Use the X-Connect-Token header if you initiated the connection via API key.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, boardId, tempToken]
properties:
profileId:
type: string
description: Your Zernio profile ID
boardId:
type: string
description: The Pinterest Board ID selected by the user
boardName:
type: string
description: The board name (for display purposes)
tempToken:
type: string
description: Temporary Pinterest access token from OAuth
userProfile:
type: object
description: User profile data from OAuth redirect
refreshToken:
type: string
description: Pinterest refresh token (if available)
expiresIn:
type: integer
description: Token expiration time in seconds
redirect_url:
type: string
format: uri
description: Custom redirect URL after connection completes
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
boardId: "123456789012345678"
boardName: "Marketing Ideas"
tempToken: "pina_..."
userProfile:
id: "user123"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://i.pinimg.com/..."
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: Pinterest Board connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url: { type: string, description: Redirect URL with connection params (if provided) }
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [pinterest] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
defaultBoardName: { type: string }
example:
message: "Pinterest connected successfully with default board"
redirect_url: "https://yourdomain.com/integrations/callback?connected=pinterest&profileId=507f1f77bcf86cd799439011&board=Marketing+Ideas"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "pinterest"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://i.pinimg.com/..."
isActive: true
defaultBoardName: "Marketing Ideas"
'400':
description: Missing required fields
content:
application/json:
example:
error: "Missing required fields"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: No access to profile or profile limit exceeded
content:
application/json:
examples:
forbidden:
value: { error: "Forbidden" }
limitExceeded:
value:
error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
code: "PROFILE_LIMIT_EXCEEDED"
'500':
description: Failed to save Pinterest connection
/v1/connect/snapchat/select-profile:
get:
operationId: listSnapchatProfiles
tags: [Connect]
summary: List Snapchat profiles
description: For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL.
parameters:
- name: X-Connect-Token
in: header
required: true
schema: { type: string }
description: Short-lived connect token from the OAuth redirect
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: tempToken
in: query
required: true
schema: { type: string }
description: Temporary Snapchat access token from the OAuth callback redirect
responses:
'200':
description: List of Snapchat Public Profiles available for connection
content:
application/json:
schema:
type: object
properties:
publicProfiles:
type: array
items:
type: object
properties:
id: { type: string, description: Snapchat Public Profile ID }
display_name: { type: string, description: Public profile display name }
username: { type: string, description: Public profile username/handle }
profile_image_url: { type: string, description: Profile image URL }
subscriber_count: { type: integer, description: Number of subscribers }
example:
publicProfiles:
- id: "abc123-def456"
display_name: "My Brand"
username: "mybrand"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 15000
- id: "xyz789-uvw012"
display_name: "Side Project"
username: "sideproject"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 5000
'400': { description: Missing required parameters (profileId or tempToken) }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to profile }
'500': { description: Failed to fetch public profiles }
post:
operationId: selectSnapchatProfile
tags: [Connect]
summary: Select Snapchat profile
description: Complete the Snapchat connection flow by saving the selected Public Profile. Snapchat requires a Public Profile to publish content. Use X-Connect-Token if connecting via API key.
parameters:
- name: X-Connect-Token
in: header
required: false
schema: { type: string }
description: Short-lived connect token from the OAuth redirect (for API users)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, selectedPublicProfile, tempToken, userProfile]
properties:
profileId:
type: string
description: Your Zernio profile ID
selectedPublicProfile:
type: object
description: The selected Snapchat Public Profile
required: [id, display_name]
properties:
id:
type: string
description: Snapchat Public Profile ID
display_name:
type: string
description: Display name of the public profile
username:
type: string
description: Username/handle
profile_image_url:
type: string
description: Profile image URL
subscriber_count:
type: integer
description: Number of subscribers
tempToken:
type: string
description: Temporary Snapchat access token from OAuth
userProfile:
type: object
description: User profile data from OAuth redirect
refreshToken:
type: string
description: Snapchat refresh token (if available)
expiresIn:
type: integer
description: Token expiration time in seconds
redirect_url:
type: string
format: uri
description: Custom redirect URL after connection completes
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
selectedPublicProfile:
id: "abc123-def456"
display_name: "My Brand"
username: "mybrand"
profile_image_url: "https://cf-st.sc-cdn.net/..."
subscriber_count: 15000
tempToken: "eyJ..."
userProfile:
id: "user123"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://cf-st.sc-cdn.net/..."
redirect_url: "https://yourapp.com/callback"
responses:
'200':
description: Snapchat Public Profile connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url: { type: string, description: Redirect URL with connection params (if provided in request) }
account:
type: object
properties:
accountId:
type: string
description: ID of the created SocialAccount
platform: { type: string, enum: [snapchat] }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
isActive: { type: boolean }
publicProfileName: { type: string }
example:
message: "Snapchat connected successfully with public profile"
redirect_url: "https://yourdomain.com/integrations/callback?connected=snapchat&profileId=507f1f77bcf86cd799439011&publicProfile=My+Brand"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "snapchat"
username: "mybrand"
displayName: "My Brand"
profilePicture: "https://cf-st.sc-cdn.net/..."
isActive: true
publicProfileName: "My Brand"
'400':
description: Missing required fields
content:
application/json:
example:
error: "Missing required fields"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: No access to profile or profile limit exceeded
content:
application/json:
examples:
forbidden:
value: { error: "Forbidden" }
limitExceeded:
value:
error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
code: "PROFILE_LIMIT_EXCEEDED"
'500':
description: Failed to connect Snapchat account
/v1/connect/bluesky/credentials:
post:
operationId: connectBlueskyCredentials
tags: [Connect]
summary: Connect Bluesky account
description: |
Connect a Bluesky account using identifier (handle or email) and an app password.
To get your userId for the state parameter, call GET /v1/users which includes a currentUserId field.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [identifier, appPassword, state]
properties:
identifier:
type: string
description: Your Bluesky handle (e.g. user.bsky.social) or email address
appPassword:
type: string
description: App password generated from Bluesky Settings > App Passwords
state:
type: string
description: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles.
example: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri:
type: string
format: uri
description: Optional URL to redirect to after successful connection
example:
identifier: "yourhandle.bsky.social"
appPassword: "xxxx-xxxx-xxxx-xxxx"
state: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri: "https://yourapp.com/connected"
responses:
'200':
description: Bluesky connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account: { $ref: '#/components/schemas/SocialAccount' }
example:
message: "Bluesky connected successfully"
account:
platform: "bluesky"
username: "yourhandle.bsky.social"
displayName: "Your Name"
isActive: true
redirectUrl: "https://zernio.com/dashboard/profiles/64f0.../accounts"
'400': { description: Invalid request - missing fields or invalid state format }
'401': { $ref: '#/components/responses/Unauthorized' }
'500': { description: Internal error }
/v1/connect/whatsapp/credentials:
post:
operationId: connectWhatsAppCredentials
tags: [Connect]
summary: Connect WhatsApp via credentials
description: |
Connect a WhatsApp Business Account by providing Meta credentials directly.
This is the headless alternative to the Embedded Signup browser flow.
To get the required credentials:
1. Go to Meta Business Suite (business.facebook.com)
2. Create or select a WhatsApp Business Account
3. In Business Settings > System Users, create a System User
4. Assign it the whatsapp_business_management and whatsapp_business_messaging permissions
5. Generate a permanent access token
6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers
7. Get the Phone Number ID from the same page (click on the number)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accessToken, wabaId, phoneNumberId]
properties:
profileId:
type: string
description: Your Zernio profile ID
accessToken:
type: string
description: Permanent System User access token from Meta Business Suite
wabaId:
type: string
description: WhatsApp Business Account ID from Meta
phoneNumberId:
type: string
description: Phone Number ID from Meta WhatsApp Manager
example:
profileId: "6507a1b2c3d4e5f6a7b8c9d0"
accessToken: "EAABsbCS...your-system-user-token"
wabaId: "123456789012345"
phoneNumberId: "987654321098765"
responses:
'200':
description: WhatsApp connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account:
type: object
properties:
accountId: { type: string }
platform: { type: string, enum: [whatsapp] }
username: { type: string, description: Display phone number }
displayName: { type: string, description: Meta-verified business name }
isActive: { type: boolean }
selectedPhoneNumber: { type: string, description: The connected phone number }
example:
message: "WhatsApp connected successfully"
account:
accountId: "6507a1b2c3d4e5f6a7b8c9d0"
platform: "whatsapp"
username: "+1 555-123-4567"
displayName: "Acme Corp"
isActive: true
selectedPhoneNumber: "+1 555-123-4567"
'400':
description: |
Invalid request. Either missing fields or the phoneNumberId was not found
in the specified WABA. If the phone was not found, the response includes
availablePhoneNumbers to help identify the correct ID.
'401':
description: Invalid or expired access token
'403':
description: Profile limit exceeded for this plan
/v1/connect/telegram:
get:
operationId: getTelegramConnectStatus
tags: [Connect]
summary: Generate Telegram code
description: Generate an access code (valid 15 minutes) for connecting a Telegram channel or group. Add the bot as admin, then send the code + @yourchannel to the bot. Poll PATCH /v1/connect/telegram to check status.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: The profile ID to connect the Telegram account to
responses:
'200':
description: Access code generated
content:
application/json:
schema:
type: object
properties:
code:
type: string
description: The access code to send to the Telegram bot
example: "ZRN-ABC123"
expiresAt:
type: string
format: date-time
description: When the code expires
expiresIn:
type: integer
description: Seconds until expiration
example: 900
botUsername:
type: string
description: The Telegram bot username to message
example: "LateScheduleBot"
instructions:
type: array
items: { type: string }
description: Step-by-step connection instructions
example:
code: "ZRN-ABC123"
expiresAt: "2024-01-15T12:30:00.000Z"
expiresIn: 900
botUsername: "LateScheduleBot"
instructions:
- "1. Add @ZernioScheduleBot as an administrator in your channel/group"
- "2. Open a private chat with @ZernioScheduleBot"
- "3. Send: ZRN-ABC123 @yourchannel (replace @yourchannel with your channel username)"
- "4. Wait for confirmation - the connection will appear in your dashboard"
- "Tip: If your channel has no public username, forward a message from it along with the code"
'400': { description: Profile ID required or invalid format }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to this profile }
'404': { description: Profile not found }
'500': { description: Internal error }
post:
operationId: initiateTelegramConnect
tags: [Connect]
summary: Connect Telegram directly
description: Connect a Telegram channel/group directly using the chat ID. Alternative to the access code flow. The bot must already be an admin in the channel/group.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [chatId, profileId]
properties:
chatId:
type: string
description: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel").
profileId:
type: string
description: The profile ID to connect the account to
example:
chatId: "-1001234567890"
profileId: "6507a1b2c3d4e5f6a7b8c9d0"
responses:
'200':
description: Telegram channel connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account:
type: object
properties:
_id: { type: string }
platform: { type: string, enum: [telegram] }
username: { type: string }
displayName: { type: string }
isActive: { type: boolean }
chatType: { type: string, enum: [channel, group, supergroup, private] }
example:
message: "Telegram channel connected successfully"
account:
_id: "64e1f0a9e2b5af0012ab34cd"
platform: "telegram"
username: "mychannel"
displayName: "My Channel"
isActive: true
chatType: "channel"
'400': { description: Chat ID required, bot not admin, or cannot access chat }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: No access to this profile }
'404': { description: Profile not found }
'500': { description: Internal error }
patch:
operationId: completeTelegramConnect
tags: [Connect]
summary: Check Telegram status
description: |
Poll this endpoint to check if a Telegram access code has been used to connect a channel/group. Recommended polling interval: 3 seconds.
Status values: pending (waiting for user), connected (channel/group linked), expired (generate a new code).
parameters:
- name: code
in: query
required: true
schema: { type: string }
description: The access code to check status for
example: "ZRN-ABC123"
responses:
'200':
description: Connection status
content:
application/json:
schema:
oneOf:
- type: object
title: Pending
properties:
status: { type: string, enum: [pending] }
expiresAt: { type: string, format: date-time }
expiresIn: { type: integer, description: Seconds until expiration }
- type: object
title: Connected
properties:
status: { type: string, enum: [connected] }
chatId: { type: string }
chatTitle: { type: string }
chatType: { type: string, enum: [channel, group, supergroup] }
account:
type: object
properties:
_id: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
- type: object
title: Expired
properties:
status: { type: string, enum: [expired] }
message: { type: string }
examples:
pending:
summary: Waiting for connection
value:
status: "pending"
expiresAt: "2024-01-15T12:30:00.000Z"
expiresIn: 542
connected:
summary: Successfully connected
value:
status: "connected"
chatId: "-1001234567890"
chatTitle: "My Channel"
chatType: "channel"
account:
_id: "64e1f0a9e2b5af0012ab34cd"
platform: "telegram"
username: "mychannel"
displayName: "My Channel"
expired:
summary: Code expired
value:
status: "expired"
message: "Access code has expired. Please generate a new one."
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Code not found }
'500': { description: Internal error }
/v1/accounts/{accountId}/facebook-page:
get:
operationId: getFacebookPages
tags: [Connect]
summary: List Facebook pages
description: Returns all Facebook pages the connected account has access to, including the currently selected page.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Pages list
content:
application/json:
schema:
type: object
properties:
pages:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
username: { type: string }
category: { type: string }
fan_count: { type: integer }
selectedPageId: { type: string }
cached: { type: boolean }
example:
pages:
- id: "123456789012345"
name: "My Brand Page"
username: "mybrand"
category: "Brand"
fan_count: 5000
- id: "234567890123456"
name: "My Other Page"
username: "myotherpage"
category: "Business"
fan_count: 1200
selectedPageId: "123456789012345"
cached: true
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
operationId: updateFacebookPage
tags: [Connect]
summary: Update Facebook page
description: Switch which Facebook Page is active for a connected account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [selectedPageId]
properties:
selectedPageId: { type: string }
example:
selectedPageId: "123456789012345"
responses:
'200':
description: Page updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
selectedPage:
type: object
properties:
id: { type: string }
name: { type: string }
example:
message: "Facebook page updated successfully"
selectedPage:
id: "123456789012345"
name: "My Brand Page"
'400': { description: Page not in available pages }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-organizations:
get:
operationId: getLinkedInOrganizations
tags: [Connect]
summary: List LinkedIn orgs
description: Returns LinkedIn organizations (company pages) the connected account has admin access to.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Organizations list
content:
application/json:
schema:
type: object
properties:
organizations:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
vanityName: { type: string }
localizedName: { type: string }
example:
organizations:
- id: "12345678"
name: "Acme Corporation"
vanityName: "acme-corp"
localizedName: "Acme Corporation"
- id: "87654321"
name: "Acme Subsidiary"
vanityName: "acme-sub"
localizedName: "Acme Subsidiary"
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-aggregate-analytics:
get:
operationId: getLinkedInAggregateAnalytics
tags: [Analytics]
summary: Get LinkedIn aggregate stats
description: Returns aggregate analytics across all posts for a LinkedIn personal account. Only includes posts published through Zernio (LinkedIn API limitation). Org accounts should use /v1/analytics instead. Requires r_member_postAnalytics scope. Saves (POST_SAVE) and sends (POST_SEND) are available for personal accounts; organization pages always return 0 for these two metrics because LinkedIn does not expose them on the organization analytics endpoint.
parameters:
- name: accountId
in: path
required: true
description: The ID of the LinkedIn personal account
schema: { type: string }
- name: aggregation
in: query
required: false
description: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY.
schema:
type: string
enum: [TOTAL, DAILY]
default: TOTAL
- name: startDate
in: query
required: false
description: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics.
schema:
type: string
format: date
example: "2024-01-01"
- name: endDate
in: query
required: false
description: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted.
schema:
type: string
format: date
example: "2024-01-31"
- name: metrics
in: query
required: false
description: "Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND. Omit for all."
schema:
type: string
example: "IMPRESSION,REACTION,COMMENT,POST_SAVE,POST_SEND"
responses:
'200':
description: Aggregate analytics data
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/LinkedInAggregateAnalyticsTotalResponse'
- $ref: '#/components/schemas/LinkedInAggregateAnalyticsDailyResponse'
examples:
totalAggregation:
summary: TOTAL aggregation (lifetime totals)
value:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "personal"
username: "John Doe"
aggregation: "TOTAL"
dateRange: null
analytics:
impressions: 1250000
reach: 450000
reactions: 7500
comments: 2500
shares: 1200
saves: 3400
sends: 900
engagementRate: 1.24
note: "Aggregate analytics across all posts on this LinkedIn personal account (lifetime totals)."
lastUpdated: "2025-01-15T10:30:00.000Z"
totalWithDateRange:
summary: TOTAL aggregation with date range
value:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "personal"
username: "John Doe"
aggregation: "TOTAL"
dateRange:
startDate: "2024-01-01"
endDate: "2024-01-31"
analytics:
impressions: 125000
reach: 45000
reactions: 750
comments: 250
shares: 120
saves: 340
sends: 90
engagementRate: 1.24
note: "Aggregate analytics for the specified date range."
lastUpdated: "2025-01-15T10:30:00.000Z"
dailyAggregation:
summary: DAILY aggregation (time series)
value:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "personal"
username: "John Doe"
aggregation: "DAILY"
dateRange:
startDate: "2024-05-04"
endDate: "2024-05-06"
analytics:
impressions:
- date: "2024-05-04"
count: 1500
- date: "2024-05-05"
count: 2300
reactions:
- date: "2024-05-04"
count: 10
- date: "2024-05-05"
count: 20
comments:
- date: "2024-05-04"
count: 3
- date: "2024-05-05"
count: 5
shares:
- date: "2024-05-04"
count: 2
- date: "2024-05-05"
count: 4
saves:
- date: "2024-05-04"
count: 8
- date: "2024-05-05"
count: 12
sends:
- date: "2024-05-04"
count: 1
- date: "2024-05-05"
count: 3
skippedMetrics:
- "MEMBERS_REACHED (not supported with DAILY aggregation)"
note: "Daily breakdown of analytics across all posts. MEMBERS_REACHED is not available with DAILY aggregation per LinkedIn API limitations."
lastUpdated: "2025-01-15T10:30:00.000Z"
'400':
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string }
validOptions: { type: array, items: { type: string } }
examples:
not_linkedin:
summary: Not a LinkedIn account
value:
error: "This endpoint only supports LinkedIn accounts"
code: "invalid_platform"
organization:
summary: Org account not supported
value:
error: "Aggregate analytics only available for LinkedIn personal accounts. Organization accounts can use per-post analytics via /v1/analytics."
code: "organization_not_supported"
invalid_aggregation:
summary: Invalid aggregation type
value:
error: "Invalid aggregation type. Must be one of: TOTAL, DAILY"
code: "invalid_aggregation"
validOptions: ["TOTAL", "DAILY"]
invalid_date:
summary: Invalid date format
value:
error: "Invalid date format. Use YYYY-MM-DD format."
code: "invalid_date_format"
example:
startDate: "2024-01-01"
endDate: "2024-01-31"
invalid_metrics:
summary: Invalid metrics requested
value:
error: "Invalid metrics: INVALID_METRIC. Valid options: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND"
code: "invalid_metrics"
validOptions: ["IMPRESSION", "MEMBERS_REACHED", "REACTION", "COMMENT", "RESHARE", "POST_SAVE", "POST_SEND"]
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string }
'403':
description: Missing required LinkedIn scope
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, example: missing_scope }
requiredScope: { type: string, example: r_member_postAnalytics }
action: { type: string, example: reconnect }
example:
error: "Missing r_member_postAnalytics scope. Please reconnect your LinkedIn account to grant analytics permissions."
code: "missing_scope"
requiredScope: "r_member_postAnalytics"
action: "reconnect"
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-post-analytics:
get:
operationId: getLinkedInPostAnalytics
tags: [Analytics]
summary: Get LinkedIn post stats
description: Returns analytics for a specific LinkedIn post by URN. Works for both personal and organization accounts. Saves and sends are only populated for personal accounts (LinkedIn does not expose these metrics on the organization analytics endpoint).
parameters:
- name: accountId
in: path
required: true
description: The ID of the LinkedIn account
schema: { type: string }
- name: urn
in: query
required: true
description: The LinkedIn post URN
schema: { type: string }
example: "urn:li:share:7123456789012345678"
responses:
'200':
description: Post analytics data
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, enum: [personal, organization] }
username: { type: string }
postUrn: { type: string }
analytics:
type: object
properties:
impressions: { type: integer, description: Times the post was shown }
reach: { type: integer, description: Unique members who saw the post }
likes: { type: integer, description: Reactions on the post }
comments: { type: integer, description: Comments on the post }
shares: { type: integer, description: Reshares of the post }
saves: { type: integer, description: Times the post was saved (personal accounts only; 0 for organization accounts) }
sends: { type: integer, description: Times the post was sent via LinkedIn messaging (personal accounts only; 0 for organization accounts) }
clicks: { type: integer, description: Clicks on the post (organization accounts only) }
views: { type: integer, description: Video views (video posts only) }
engagementRate: { type: number, description: Engagement rate as percentage }
lastUpdated: { type: string, format: date-time }
example:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "personal"
username: "John Doe"
postUrn: "urn:li:share:7123456789012345678"
analytics:
impressions: 5420
reach: 3200
likes: 156
comments: 23
shares: 12
saves: 48
sends: 9
clicks: 0
views: 1250
engagementRate: 6.22
lastUpdated: "2025-01-15T10:30:00.000Z"
'400':
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform] }
examples:
missing_urn:
value:
error: "Missing required parameter: urn"
code: "missing_urn"
example: "urn:li:share:7123456789012345678 or urn:li:ugcPost:7123456789012345678"
invalid_urn:
value:
error: "Invalid URN format. Must be urn:li:share:ID or urn:li:ugcPost:ID"
code: "invalid_urn"
providedUrn: "invalid-urn"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
'403':
description: Missing required LinkedIn scope
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, example: missing_scope }
requiredScope: { type: string }
action: { type: string, example: reconnect }
'404':
description: Account or post not found
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string }
examples:
account_not_found:
value:
error: "Account not found"
post_not_found:
value:
error: "Post not found. The URN may be invalid or the post may have been deleted."
code: "post_not_found"
postUrn: "urn:li:share:123"
/v1/accounts/{accountId}/linkedin-post-reactions:
get:
operationId: getLinkedInPostReactions
tags: [Analytics]
summary: Get LinkedIn post reactions
description: |
Returns individual reactions for a specific LinkedIn post, including reactor profiles
(name, headline/job title, profile picture, profile URL, reaction type).
Only works for organization/company page accounts. LinkedIn restricts reaction
data for personal profiles (r_member_social_feed is a closed permission).
parameters:
- name: accountId
in: path
required: true
description: The ID of the LinkedIn organization account
schema: { type: string }
- name: urn
in: query
required: true
description: The LinkedIn post URN
schema: { type: string }
example: "urn:li:share:7123456789012345678"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
description: Maximum number of reactions to return per page
- name: cursor
in: query
schema: { type: string }
description: Offset-based pagination start index
responses:
'200':
description: Reactions with reactor profiles
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: organization }
username: { type: string }
postUrn: { type: string }
reactions:
type: array
items:
type: object
properties:
reactionType:
type: string
description: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
reactionLabel:
type: string
description: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny)
reactedAt: { type: string, format: date-time }
from:
type: object
properties:
urn: { type: string, description: "LinkedIn person or organization URN" }
name: { type: string, description: "Reactor's display name" }
headline: { type: string, description: "Reactor's headline/job title" }
username: { type: string, description: "LinkedIn vanity name" }
profilePicture: { type: string, description: "Profile picture URL" }
profileUrl: { type: string, description: "Direct link to LinkedIn profile" }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: string, description: "Offset for next page" }
total: { type: integer, description: "Total number of reactions (when available)" }
lastUpdated: { type: string, format: date-time }
example:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "organization"
username: "Acme Corp"
postUrn: "urn:li:share:7123456789012345678"
reactions:
- reactionType: "LIKE"
reactionLabel: "Like"
reactedAt: "2026-03-08T12:00:00.000Z"
from:
urn: "urn:li:person:abc123"
name: "Jane Smith"
headline: "Product Manager at Acme Corp"
username: "janesmith"
profilePicture: "https://media.licdn.com/..."
profileUrl: "https://www.linkedin.com/in/janesmith"
pagination:
hasMore: true
cursor: "25"
total: 156
lastUpdated: "2026-03-08T12:00:00.000Z"
'400':
description: Invalid request or platform limitation
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform, PLATFORM_LIMITATION] }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics add-on required
'403':
description: Missing required LinkedIn scope
'404':
description: Account or post not found
/v1/accounts/{accountId}/linkedin-organization:
put:
operationId: updateLinkedInOrganization
tags: [Connect]
summary: Switch LinkedIn account type
description: Switch a LinkedIn account between personal profile and organization (company page) posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountType]
properties:
accountType: { type: string, enum: [personal, organization] }
selectedOrganization: { type: object }
example:
accountType: "organization"
selectedOrganization:
id: "12345678"
name: "Acme Corporation"
vanityName: "acme-corp"
responses:
'200':
description: Account updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account: { $ref: '#/components/schemas/SocialAccount' }
example:
message: "LinkedIn account type updated successfully"
account:
_id: "64e1f0a9e2b5af0012ab34cd"
platform: "linkedin"
username: "acme-corp"
displayName: "Acme Corporation"
isActive: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-mentions:
get:
operationId: getLinkedInMentions
tags: [LinkedIn Mentions]
summary: Resolve LinkedIn mention
description: |
Converts a LinkedIn profile or company URL to a URN for @mentions in posts.
How to use LinkedIn @mentions (2-step workflow):
1. Call this endpoint with the LinkedIn profile/company URL to get the mention URN and format.
2. Embed the returned mentionFormat (e.g. @[Vincent Jong](urn:li:person:xxx)) directly in your post's content field.
Example:
- Resolve: GET /v1/accounts/{id}/linkedin-mentions?url=linkedin.com/in/vincentjong&displayName=Vincent Jong
- Returns: mentionFormat: "@[Vincent Jong](urn:li:person:xxx)"
- Use in post content: "Great talk with @[Vincent Jong](urn:li:person:xxx) today!"
Important: The mentions array field in POST /v1/posts is stored for reference only and does NOT trigger @mentions on LinkedIn. You must embed the mention format directly in the content text.
Requirements:
- Person mentions require the LinkedIn account to be admin of at least one organization. This is a LinkedIn API limitation: the only endpoints that resolve profile URLs to member URNs (vanityUrl, peopleTypeahead) are scoped to organization followers. There is no public LinkedIn API to resolve a vanity URL without organization context.
- Organization mentions (e.g. @Microsoft) work without this requirement.
- For person mentions to be clickable, the displayName parameter must exactly match the name shown on their LinkedIn profile.
- Person mentions DO work when published from personal profiles (the URN just needs to be valid). The limitation is only in the resolution step (URL to URN), not in publishing.
parameters:
- name: accountId
in: path
required: true
description: The LinkedIn account ID
schema: { type: string }
- name: url
in: query
required: true
description: LinkedIn profile URL, company URL, or vanity name.
schema: { type: string }
examples:
personVanityName:
value: "miquelpalet"
summary: Person - just the vanity name
personFullUrl:
value: "https://www.linkedin.com/in/miquelpalet"
summary: Person - full LinkedIn URL
orgShortUrl:
value: "company/microsoft"
summary: Org - short format
orgFullUrl:
value: "https://www.linkedin.com/company/microsoft"
summary: Org - full LinkedIn URL
- name: displayName
in: query
required: false
description: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions.
schema: { type: string }
examples:
personName:
value: "Miquel Palet"
summary: Exact name as shown on LinkedIn profile
orgName:
value: "Microsoft"
summary: Company name (optional for orgs)
responses:
'200':
description: URN resolved successfully
content:
application/json:
schema:
type: object
properties:
urn:
type: string
description: The LinkedIn URN (person or organization)
example: "urn:li:person:4qj5ox-agD"
type:
type: string
enum: [person, organization]
description: The type of entity (person or organization)
example: "person"
displayName:
type: string
description: Display name (provided, from API, or derived from vanity URL)
example: "Miquel Palet"
mentionFormat:
type: string
description: Ready-to-use mention format for post content
example: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
vanityName:
type: string
description: The vanity name/slug (only for organization mentions)
example: "microsoft"
warning:
type: string
description: Warning about clickable mentions (only present for person mentions if displayName was not provided)
example: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
examples:
personWithDisplayName:
summary: Person mention with displayName (recommended)
value:
urn: "urn:li:person:4qj5ox-agD"
type: "person"
displayName: "Miquel Palet"
mentionFormat: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
personWithoutDisplayName:
summary: Person mention without displayName (may not be clickable)
value:
urn: "urn:li:person:4qj5ox-agD"
type: "person"
displayName: "Miquelpalet"
mentionFormat: "@[Miquelpalet](urn:li:person:4qj5ox-agD)"
warning: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
organization:
summary: Org mention
value:
urn: "urn:li:organization:1035"
type: "organization"
displayName: "Microsoft"
mentionFormat: "@[Microsoft](urn:li:organization:1035)"
vanityName: "microsoft"
'400':
description: Invalid request or no organization found (for person mentions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
missingUrl:
value: { error: "url parameter is required" }
noOrgForPersonMention:
value: { error: "No organization found. You need to be an admin of a LinkedIn organization to use person mentions. Organization mentions work without this requirement." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Person or organization not found
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
memberNotFound:
value: { error: "Member not found. Check the LinkedIn URL is correct." }
orgNotFound:
value: { error: "Organization not found. Check the LinkedIn company URL is correct." }
/v1/accounts/{accountId}/pinterest-boards:
get:
operationId: getPinterestBoards
tags: [Connect]
summary: List Pinterest boards
description: Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Boards list
content:
application/json:
schema:
type: object
properties:
boards:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
privacy: { type: string }
example:
boards:
- id: "123456789012345678"
name: "Marketing Ideas"
description: "Collection of marketing inspiration"
privacy: "PUBLIC"
- id: "234567890123456789"
name: "Product Photos"
description: "Product photography"
privacy: "PUBLIC"
'400': { description: Not a Pinterest account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
operationId: updatePinterestBoards
tags: [Connect]
summary: Set default Pinterest board
description: Sets the default board used when publishing pins for this account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultBoardId]
properties:
defaultBoardId: { type: string }
defaultBoardName: { type: string }
example:
defaultBoardId: "123456789012345678"
defaultBoardName: "Marketing Ideas"
responses:
'200':
description: Default board set
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account: { $ref: '#/components/schemas/SocialAccount' }
example:
message: "Default Pinterest board updated successfully"
account:
_id: "64e1f0a9e2b5af0012ab34cd"
platform: "pinterest"
username: "mybrand"
displayName: "My Brand"
isActive: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/youtube-playlists:
get:
operationId: getYoutubePlaylists
tags: [Connect]
summary: List YouTube playlists
description: Returns the playlists available for a connected YouTube account. Use this to get a playlist ID when creating a YouTube post with the playlistId field.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Playlists list
content:
application/json:
schema:
type: object
properties:
playlists:
type: array
items:
type: object
properties:
id: { type: string }
title: { type: string }
description: { type: string }
privacy: { type: string, enum: [public, private, unlisted] }
itemCount: { type: integer }
thumbnailUrl: { type: string }
defaultPlaylistId:
type: string
nullable: true
example:
playlists:
- id: "PLxxxxxxxxxxxxx"
title: "Tutorials"
description: "Step-by-step video tutorials"
privacy: "public"
itemCount: 24
thumbnailUrl: "https://i.ytimg.com/vi/xxx/mqdefault.jpg"
- id: "PLyyyyyyyyyyyyy"
title: "Vlogs"
description: "Weekly vlogs"
privacy: "public"
itemCount: 52
thumbnailUrl: "https://i.ytimg.com/vi/yyy/mqdefault.jpg"
defaultPlaylistId: null
'400': { description: Not a YouTube account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
operationId: updateYoutubeDefaultPlaylist
tags: [Connect]
summary: Set default YouTube playlist
description: Sets the default playlist used when publishing videos for this account. When a post does not specify a playlistId, the default playlist is not automatically used (it is stored for client-side convenience).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultPlaylistId]
properties:
defaultPlaylistId: { type: string }
defaultPlaylistName: { type: string }
example:
defaultPlaylistId: "PLxxxxxxxxxxxxx"
defaultPlaylistName: "Tutorials"
responses:
'200':
description: Default playlist set
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/gmb-locations:
get:
operationId: getGmbLocations
tags: [Connect]
summary: List GBP locations
description: Returns all Google Business Profile locations the connected account has access to, including the currently selected location.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
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 }
selectedLocationId: { type: string }
cached: { type: boolean }
example:
locations:
- id: "12345678901234567890"
name: "My Business Location"
accountId: "accounts/123456789"
accountName: "My Business Account"
address: "123 Main St, San Francisco, CA"
category: "Restaurant"
websiteUrl: "https://mybusiness.com"
selectedLocationId: "12345678901234567890"
cached: true
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
operationId: updateGmbLocation
tags: [Connect]
summary: Update GBP location
description: Switch which GBP location is active for a connected account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [selectedLocationId]
properties:
selectedLocationId: { type: string }
example:
selectedLocationId: "12345678901234567890"
responses:
'200':
description: Location updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
selectedLocation:
type: object
properties:
id: { type: string }
name: { type: string }
example:
message: "Google Business location updated successfully"
selectedLocation:
id: "12345678901234567890"
name: "My Business Location"
'400': { description: Location not in available locations }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/reddit-subreddits:
get:
operationId: getRedditSubreddits
tags: [Connect]
summary: List Reddit subreddits
description: Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Subreddits list
content:
application/json:
schema:
type: object
properties:
subreddits:
type: array
items:
type: object
properties:
id: { type: string, description: Reddit subreddit ID }
name: { type: string, description: Subreddit name without r/ prefix }
title: { type: string, description: Subreddit title }
url: { type: string, description: Subreddit URL path }
over18: { type: boolean, description: Whether the subreddit is NSFW }
defaultSubreddit:
type: string
description: Currently set default subreddit for posting
example:
subreddits:
- id: "2qh1i"
name: "marketing"
title: "Marketing"
url: "/r/marketing/"
over18: false
- id: "2qh3l"
name: "socialmedia"
title: "Social Media"
url: "/r/socialmedia/"
over18: false
defaultSubreddit: "marketing"
'400': { description: Not a Reddit account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
operationId: updateRedditSubreddits
tags: [Connect]
summary: Set default subreddit
description: Sets the default subreddit used when publishing posts for this Reddit account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultSubreddit]
properties:
defaultSubreddit: { type: string }
example:
defaultSubreddit: "marketing"
responses:
'200':
description: Default subreddit set
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/reddit-flairs:
get:
operationId: getRedditFlairs
tags: [Connect]
summary: List subreddit flairs
description: Returns available post flairs for a subreddit. Some subreddits require a flair when posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: subreddit
in: query
required: true
schema: { type: string }
description: Subreddit name (without "r/" prefix) to fetch flairs for
responses:
'200':
description: Flairs list
content:
application/json:
schema:
type: object
properties:
flairs:
type: array
items:
type: object
properties:
id: { type: string, description: Flair ID to pass as flairId in platformSpecificData }
text: { type: string, description: Flair display text }
textColor: { type: string, description: "Text color: 'dark' or 'light'" }
backgroundColor: { type: string, description: "Background hex color (e.g. '#ff4500')" }
example:
flairs:
- id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
text: "Discussion"
textColor: "dark"
backgroundColor: "#edeff1"
- id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
text: "News"
textColor: "light"
backgroundColor: "#ff4500"
'400': { description: Not a Reddit account or missing subreddit parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/discord-settings:
get:
operationId: getDiscordSettings
tags: [Discord]
summary: Get Discord account settings
description: Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Discord account settings
content:
application/json:
schema:
type: object
properties:
account:
type: object
properties:
_id: { type: string }
platform: { type: string, example: discord }
username: { type: string, description: Channel name }
displayName: { type: string, description: "Guild - #channel display name" }
profilePicture: { type: string, description: Guild icon URL }
channelId: { type: string, description: Connected channel snowflake ID }
channelName: { type: string, description: Channel name }
channelType: { type: string, description: "Channel type (0 = text, 5 = announcement, 15 = forum)" }
guildId: { type: string, description: Guild (server) snowflake ID }
webhookUsername: { type: string, nullable: true, description: Custom webhook display name (null = default "Zernio") }
webhookAvatarUrl: { type: string, nullable: true, description: Custom webhook avatar URL (null = default bot avatar) }
example:
account:
_id: "abc123"
platform: "discord"
username: "announcements"
displayName: "My Server - #announcements"
profilePicture: "https://cdn.discordapp.com/icons/123/abc.png"
channelId: "1234567890123456789"
channelName: "announcements"
channelType: "0"
guildId: "9876543210987654321"
webhookUsername: "My Brand"
webhookAvatarUrl: "https://example.com/logo.png"
'400': { description: Not a Discord account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
patch:
operationId: updateDiscordSettings
tags: [Discord]
summary: Update Discord settings
description: |
Update Discord account settings. Supports two operations (can be combined):
1. **Webhook identity** - Set the default display name and avatar that appear as the message author on every post. These are account-level defaults; individual posts can override them via platformSpecificData.webhookUsername / webhookAvatarUrl.
2. **Switch channel** - Move the connection to a different channel in the same guild. A new webhook is automatically created in the target channel.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
webhookUsername:
type: string
description: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord".
webhookAvatarUrl:
type: string
description: Custom avatar URL. Empty string resets to default bot avatar.
channelId:
type: string
description: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel.
examples:
identity:
summary: Update webhook identity
value:
webhookUsername: "My Brand"
webhookAvatarUrl: "https://example.com/logo.png"
channel:
summary: Switch channel
value:
channelId: "9999999999999999999"
responses:
'200':
description: Settings updated
content:
application/json:
schema:
type: object
properties:
message: { type: string, example: Discord settings updated }
account:
type: object
properties:
_id: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
channelId: { type: string }
channelName: { type: string }
channelType: { type: string }
guildId: { type: string }
webhookUsername: { type: string, nullable: true }
webhookAvatarUrl: { type: string, nullable: true }
'400': { description: Invalid request (no changes, invalid channel type, or bot cannot access channel) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found }
/v1/accounts/{accountId}/discord-channels:
get:
operationId: getDiscordChannels
tags: [Discord]
summary: List Discord guild channels
description: Returns the text, announcement, and forum channels in the connected Discord guild. Use this to discover available channels when switching the connected channel via PATCH /v1/accounts/{accountId}/discord-settings.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Channel list
content:
application/json:
schema:
type: object
properties:
channels:
type: array
items:
type: object
properties:
id: { type: string, description: Channel snowflake ID }
name: { type: string, description: Channel name }
type: { type: integer, description: "Channel type: 0 (text), 5 (announcement), 15 (forum)" }
example:
channels:
- id: "1234567890123456789"
name: "general"
type: 0
- id: "2345678901234567890"
name: "announcements"
type: 5
- id: "3456789012345678901"
name: "feedback"
type: 15
'400': { description: Not a Discord account or missing guild info }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/queue/slots:
get:
operationId: listQueueSlots
tags: [Queue]
summary: List schedules
description: Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID to get queues for
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID to retrieve (optional)
- name: all
in: query
required: false
schema: { type: string, enum: ['true'] }
description: Set to 'true' to list all queues for the profile
responses:
'200':
description: Queue schedule(s) retrieved
content:
application/json:
schema:
oneOf:
- 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 }
- type: object
description: All queues response (when all=true)
properties:
queues:
type: array
items:
$ref: '#/components/schemas/QueueSchedule'
count: { type: integer }
examples:
singleQueue:
summary: Single queue response
value:
exists: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
allQueues:
summary: All queues response (all=true)
value:
queues:
- _id: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
isDefault: true
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "09:00" }]
active: true
- _id: "64f0a1b2c3d4e5f6a7b8c9d2"
name: "Evening Content"
isDefault: false
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "18:00" }]
active: true
count: 2
'400': { description: Missing profileId }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
post:
operationId: createQueueSlot
tags: [Queue]
summary: Create schedule
description: |
Create an additional queue for a profile. The first queue created becomes the default.
Subsequent queues are non-default unless explicitly set.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, timezone, slots]
properties:
profileId: { type: string, description: Profile ID }
name: { type: string, description: "Queue name (e.g., Evening Posts)" }
timezone: { type: string, description: IANA timezone }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Evening Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "18:00"
- dayOfWeek: 3
time: "18:00"
- dayOfWeek: 5
time: "18:00"
active: true
responses:
'201':
description: Queue created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
schedule:
$ref: '#/components/schemas/QueueSchedule'
nextSlots:
type: array
items: { type: string, format: date-time }
'400': { description: Invalid request or validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
put:
operationId: updateQueueSlot
tags: [Queue]
summary: Update schedule
description: |
Create a new queue or update an existing one. Without queueId, creates/updates the default queue. With queueId, updates a specific queue. With setAsDefault=true, makes this queue the default for the profile.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, timezone, slots]
properties:
profileId: { type: string }
queueId: { type: string, description: Queue ID to update (optional) }
name: { type: string, description: Queue name }
timezone: { type: string }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
setAsDefault: { type: boolean, description: Make this queue the default }
reshuffleExisting:
type: boolean
default: false
description: Whether to reschedule existing queued posts to match new slots
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
setAsDefault: false
responses:
'200':
description: Queue schedule updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
schedule:
$ref: '#/components/schemas/QueueSchedule'
nextSlots:
type: array
items: { type: string, format: date-time }
reshuffledCount: { type: integer }
example:
success: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
reshuffledCount: 0
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
delete:
operationId: deleteQueueSlot
tags: [Queue]
summary: Delete schedule
description: |
Delete a queue from a profile. Requires queueId to specify which queue to delete.
If deleting the default queue, another queue will be promoted to default.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: true
schema: { type: string }
description: Queue ID to delete
responses:
'200':
description: Queue schedule deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: boolean }
example:
success: true
deleted: true
'400': { description: Missing profileId or queueId }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/queue/preview:
get:
operationId: previewQueue
tags: [Queue]
summary: Preview upcoming slots
description: Returns the next N upcoming queue slot times for a profile as ISO datetime strings.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
schema: { type: string }
description: Filter by specific queue ID. Omit to use the default queue.
- name: count
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: Queue slots preview
content:
application/json:
schema:
type: object
properties:
profileId: { type: string }
count: { type: integer }
slots:
type: array
items: { type: string, format: date-time }
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
count: 10
slots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-04T14:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
- "2024-11-08T10:00:00-05:00"
- "2024-11-11T09:00:00-05:00"
- "2024-11-11T14:00:00-05:00"
- "2024-11-13T09:00:00-05:00"
- "2024-11-15T10:00:00-05:00"
- "2024-11-18T09:00:00-05:00"
- "2024-11-18T14:00:00-05:00"
'400': { description: Invalid parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile or queue schedule not found }
/v1/queue/next-slot:
get:
operationId: getNextQueueSlot
tags: [Queue]
summary: Get next available slot
description: Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID (optional, defaults to profile's default queue)
responses:
'200':
description: Next available slot
content:
application/json:
schema:
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 }
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
nextSlot: "2024-11-04T09:00:00-05:00"
timezone: "America/New_York"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
queueName: "Morning Posts"
'400':
description: Invalid parameters or inactive queue
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: "Profile or queue schedule not found, or no available slots"
# ============================================
# Webhooks API (Multi-Webhook System)
# ============================================
/v1/webhooks/settings:
get:
operationId: getWebhookSettings
tags: [Webhooks]
summary: List webhooks
description: Retrieve all configured webhooks for the authenticated user. Supports up to 10 webhooks per user.
security:
- bearerAuth: []
responses:
'200':
description: Webhooks retrieved successfully
content:
application/json:
schema:
type: object
properties:
webhooks:
type: array
items:
$ref: '#/components/schemas/Webhook'
example:
webhooks:
- _id: "507f1f77bcf86cd799439011"
name: "My Production Webhook"
url: "https://example.com/webhook"
events: ["post.published", "post.failed"]
isActive: true
lastFiredAt: "2024-01-15T10:30:00Z"
failureCount: 0
- _id: "507f1f77bcf86cd799439012"
name: "Slack Notifications"
url: "https://hooks.slack.com/services/xxx"
events: ["post.failed", "account.disconnected"]
isActive: true
failureCount: 0
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createWebhookSettings
tags: [Webhooks]
summary: Create webhook
description: |
Create a new webhook configuration. Maximum 10 webhooks per user.
`name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are automatically disabled after 10 consecutive delivery failures.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- url
- events
properties:
name:
type: string
description: Webhook name (1-50 characters)
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed)
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, comment.received, review.new, review.updated]
description: Events to subscribe to (at least one required)
isActive:
type: boolean
default: true
description: Enable or disable webhook delivery. Defaults to `true` when omitted.
customHeaders:
type: object
additionalProperties:
type: string
description: Custom headers to include in webhook requests
examples:
createWebhook:
summary: Create webhook with all events
value:
name: "My Production Webhook"
url: "https://example.com/webhook"
secret: "your-secret-key"
events: ["post.scheduled", "post.published", "post.failed", "post.partial", "post.cancelled", "post.recycled", "account.connected", "account.disconnected", "account.ads.initial_sync_completed", "message.received", "comment.received", "review.new", "review.updated"]
isActive: true
responses:
'200':
description: Webhook created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
webhook:
$ref: '#/components/schemas/Webhook'
'400': { description: Validation error or maximum webhooks reached }
'401': { $ref: '#/components/responses/Unauthorized' }
put:
operationId: updateWebhookSettings
tags: [Webhooks]
summary: Update webhook
description: |
Update an existing webhook configuration. All fields except `_id` are optional; only provided fields will be updated.
When provided, `name` must be 1-50 characters, `url` must be a valid URL, and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are automatically disabled after 10 consecutive delivery failures.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- _id
properties:
_id:
type: string
description: Webhook ID to update (required)
name:
type: string
description: Webhook name (1-50 characters). Must be non-empty if provided.
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided.
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, comment.received, review.new, review.updated]
description: Events to subscribe to. Must contain at least one event if provided.
isActive:
type: boolean
description: Enable or disable webhook delivery
customHeaders:
type: object
additionalProperties:
type: string
description: Custom headers to include in webhook requests
examples:
updateWebhook:
summary: Update webhook URL and events
value:
_id: "507f1f77bcf86cd799439011"
url: "https://new-example.com/webhook"
events: ["post.published", "post.failed"]
toggleWebhook:
summary: Enable/disable webhook
value:
_id: "507f1f77bcf86cd799439011"
isActive: false
responses:
'200':
description: Webhook updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
webhook:
$ref: '#/components/schemas/Webhook'
'400': { description: Validation error or missing webhook ID }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Webhook not found }
delete:
operationId: deleteWebhookSettings
tags: [Webhooks]
summary: Delete webhook
description: Permanently delete a webhook configuration.
security:
- bearerAuth: []
parameters:
- name: id
in: query
required: true
description: Webhook ID to delete
schema:
type: string
responses:
'200':
description: Webhook deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Webhook ID required }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/webhooks/test:
post:
operationId: testWebhook
tags: [Webhooks]
summary: Send test webhook
description: |
Send a test webhook to verify your endpoint is configured correctly. The test payload includes event: "webhook.test" to distinguish it from real events.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- webhookId
properties:
webhookId:
type: string
description: ID of the webhook to test
example:
webhookId: "507f1f77bcf86cd799439011"
responses:
'200':
description: Test webhook sent successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Test webhook sent successfully"
'400': { description: Webhook ID required }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Test webhook failed to deliver
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: false
message: "Test webhook failed"
/v1/logs:
get:
operationId: listLogs
tags: [Logs]
summary: List activity logs
description: |
Unified logs endpoint. Returns logs for publishing, connections, webhooks, and messaging.
Filter by type, platform, status, and time range. Logs are retained for 90 days.
security:
- bearerAuth: []
parameters:
- name: type
in: query
description: Log category to query
schema:
type: string
enum: [publishing, connections, webhooks, messaging]
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
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
pagination:
type: object
properties:
total:
type: integer
limit:
type: integer
skip:
type: integer
pages:
type: integer
hasMore:
type: boolean
'401': { $ref: '#/components/responses/Unauthorized' }
# Unified Inbox Endpoints
/v1/inbox/conversations:
get:
operationId: listInboxConversations
summary: List conversations
description: |
Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.
Supported platforms: Facebook, Instagram, Twitter/X, Bluesky, Reddit, Telegram.
Twitter/X limitation: X has replaced traditional DMs with encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not accessible via X's API (the /2/dm_events endpoint only returns legacy unencrypted DMs). This means some Twitter/X conversations may show only outgoing messages or appear empty. This is an X platform limitation that affects all third-party applications. See X's docs on encrypted messaging for more details.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: platform
in: query
schema: { type: string, enum: [facebook, instagram, twitter, bluesky, reddit, telegram] }
description: Filter by platform
- name: status
in: query
schema: { type: string, enum: [active, archived] }
description: Filter by conversation status
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order by updated time
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
description: Maximum number of conversations to return
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor for next page
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated conversations
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string }
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
participantId: { type: string }
participantName: { type: string }
participantPicture: { type: string, nullable: true }
participantVerifiedType:
type: string
nullable: true
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, nullable: true, description: Number of unread messages }
url:
type: string
nullable: true
description: Direct link to open the conversation on the platform (if available)
instagramProfile:
type: object
nullable: true
description: Instagram profile data for the participant. Only present for Instagram conversations.
properties:
isFollower:
type: boolean
nullable: true
description: Whether the participant follows your Instagram business account
isFollowing:
type: boolean
nullable: true
description: Whether your Instagram business account follows the participant
followerCount:
type: integer
nullable: true
description: The participant's follower count on Instagram
isVerified:
type: boolean
nullable: true
description: Whether the participant is a verified Instagram user
fetchedAt:
type: string
format: date-time
nullable: true
description: When this profile data was last fetched from Instagram
pagination:
type: object
properties:
hasMore: { type: boolean }
nextCursor: { type: string, nullable: true }
meta:
type: object
properties:
accountsQueried: { type: integer }
accountsFailed: { type: integer }
failedAccounts:
type: array
items:
type: object
properties:
accountId: { type: string }
accountUsername: { type: string, nullable: true }
platform: { type: string }
error: { type: string }
code: { type: string, nullable: true, description: Error code if available }
retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
lastUpdated: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
post:
operationId: createInboxConversation
summary: Create conversation
description: |
Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread.
Currently supported platforms: Twitter/X only. Other platforms will return PLATFORM_NOT_SUPPORTED.
DM eligibility: Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility.
X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials.
Rate limits: 200 requests per 15 minutes, 1,000 per 24 hours per user, 15,000 per 24 hours per app (shared across all DM endpoints).
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: The social account ID to send from
participantId:
type: string
description: Twitter numeric user ID of the recipient. Provide either this or participantUsername.
participantUsername:
type: string
description: Twitter username (with or without @) of the recipient. Resolved to a user ID via lookup. Provide either this or participantId.
message:
type: string
description: Text content of the message. At least one of message or attachment is required.
skipDmCheck:
type: boolean
default: false
description: Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs.
multipart/form-data:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: The social account ID to send from
participantId:
type: string
description: Twitter numeric user ID of the recipient
participantUsername:
type: string
description: Twitter username (with or without @) of the recipient
message:
type: string
description: Text content of the message
attachment:
type: string
format: binary
description: Media attachment (image or video). One attachment per message.
skipDmCheck:
type: string
enum: ['true', 'false']
default: 'false'
description: Skip the DM eligibility check
responses:
'201':
description: Conversation created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
data:
type: object
properties:
messageId:
type: string
description: Platform message ID (dm_event_id)
conversationId:
type: string
description: Platform conversation ID (dm_conversation_id)
participantId:
type: string
description: Twitter numeric user ID of the recipient
participantName:
type: string
nullable: true
description: Display name of the recipient
participantUsername:
type: string
nullable: true
description: Twitter username of the recipient
'400':
description: Validation error or platform not supported
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PLATFORM_NOT_SUPPORTED] }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required or profile limit reached
'404':
description: Account or recipient user not found
'422':
description: Recipient does not accept DMs from this account
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [DM_NOT_ALLOWED] }
'429':
description: X API rate limit exceeded
/v1/inbox/conversations/{conversationId}:
get:
operationId: getInboxConversation
summary: Get conversation
description: Retrieve details and metadata for a specific conversation. Requires accountId query parameter.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
- name: accountId
in: query
required: true
schema: { type: string }
description: The social account ID
responses:
'200':
description: Conversation details
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id: { type: string }
accountId: { type: string }
accountUsername: { type: string }
platform: { type: string }
status: { type: string, enum: [active, archived] }
participantName: { type: string }
participantId: { type: string }
participantVerifiedType:
type: string
nullable: true
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
nullable: true
description: Instagram profile data for the participant. Only present for Instagram conversations.
properties:
isFollower:
type: boolean
nullable: true
description: Whether the participant follows your Instagram business account
isFollowing:
type: boolean
nullable: true
description: Whether your Instagram business account follows the participant
followerCount:
type: integer
nullable: true
description: The participant's follower count on Instagram
isVerified:
type: boolean
nullable: true
description: Whether the participant is a verified Instagram user
fetchedAt:
type: string
format: date-time
nullable: true
description: When this profile data was last fetched from Instagram
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Conversation not found
put:
operationId: updateInboxConversation
summary: Update conversation status
description: Archive or activate a conversation. Requires accountId in request body.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, status]
properties:
accountId: { type: string, description: Social account ID }
status: { type: string, enum: [active, archived] }
responses:
'200':
description: Conversation updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
id: { type: string }
accountId: { type: string }
status: { type: string, enum: [active, archived] }
platform: { type: string }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Conversation not found (WhatsApp only; other platforms upsert)
/v1/inbox/conversations/{conversationId}/messages:
get:
operationId: getInboxConversationMessages
summary: List messages
description: |
Fetch messages for a specific conversation, with cursor-based pagination
and ordering control.
Pagination: pass `pagination.nextCursor` from a prior response back as
the `cursor` query param to fetch the next page. The cursor is opaque;
do not parse or construct it client-side.
Sort order: defaults to `asc` (oldest first, chat style). For the
"show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`.
For Twitter, Facebook and Bluesky, the upstream APIs only return
newest-first and have no order parameter — sort order is best-effort
and only reverses items within a single page (pages still walk
newest→oldest). The response field `sortOrderApplied` tells you what
was actually applied.
Reddit threads are paginated client-side because Reddit's API has no
per-thread cursor. Very long threads may be upstream-truncated by
Reddit's inbox/sent windows (~100 most-recent items each); this is a
Reddit platform limitation.
Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
description: Number of messages to return per page. Default 100, max 100.
- name: cursor
in: query
required: false
schema: { type: string }
description: Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response.
- name: sortOrder
in: query
required: false
schema: { type: string, enum: [asc, desc], default: asc }
description: |
Order of returned messages. Default `asc` (oldest first, chat style).
For Twitter, 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
nullable: true
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 Twitter,
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, nullable: true }
senderVerifiedType:
type: string
nullable: true
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X messages.
direction: { type: string, enum: [incoming, outgoing] }
createdAt: { type: string, format: date-time }
attachments:
type: array
items:
type: object
properties:
id: { type: string }
type: { type: string, enum: [image, video, audio, file, sticker, share] }
url: { type: string }
filename: { type: string, nullable: true }
previewUrl: { type: string, nullable: true }
subject: { type: string, nullable: true, description: Reddit message subject }
storyReply: { type: boolean, nullable: true, description: Instagram story reply }
isStoryMention: { type: boolean, nullable: true, 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
format: date-time
nullable: true
description: When the most recent edit happened.
editCount:
type: integer
description: Total number of edits applied.
editHistory:
type: array
description: Every prior version of the message, oldest first.
items:
type: object
properties:
text: { type: string, nullable: true }
attachments:
type: array
items:
type: object
properties:
type: { type: string }
url: { type: string }
payload: { type: object }
editedAt: { type: string, format: date-time }
isDeleted:
type: boolean
description: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated.
deletedAt:
type: string
format: date-time
nullable: true
deliveryStatus:
type: string
enum: [sent, delivered, read, failed, deleted]
nullable: true
description: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix).
deliveredAt:
type: string
format: date-time
nullable: true
readAt:
type: string
format: date-time
nullable: true
sentAt:
type: string
format: date-time
nullable: true
description: Original send time for outgoing messages (used for Messenger watermark queries).
deliveryError:
type: object
nullable: true
description: Populated when deliveryStatus === "failed".
properties:
code: { type: integer }
title: { type: string }
message: { type: string }
lastUpdated: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
post:
operationId: sendInboxMessage
summary: Send message
description: |
Send a message in a conversation. Supports text, attachments, quick replies,
buttons, templates, and message tags. Attachment and interactive message
support varies by platform.
WhatsApp rich interactive messages (list, CTA URL, Flow) are available via
the `interactive` field. Tap events are delivered through the
`message.received` webhook with WhatsApp-specific `metadata` fields
(`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`).
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
message: { type: string, description: Message text }
attachmentUrl: { type: string, description: "URL of the attachment to send (image, video, audio, or file). The URL must be publicly accessible. For binary file uploads, use multipart/form-data instead." }
attachmentType:
type: string
enum: [image, video, audio, file]
description: "Type of attachment. Defaults to file if not specified."
quickReplies:
type: array
maxItems: 13
description: Quick reply buttons. Mutually exclusive with buttons. Max 13 items.
items:
type: object
required: [title, payload]
properties:
title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
payload: { type: string, description: Payload sent back on tap }
imageUrl: { type: string, description: Optional icon URL (Meta only) }
buttons:
type: array
maxItems: 3
description: Action buttons. Mutually exclusive with quickReplies. Max 3 items.
items:
type: object
required: [type, title]
properties:
type: { type: string, enum: [url, postback, phone], description: Button type. phone is Facebook only. }
title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
url: { type: string, description: URL for url-type buttons }
payload: { type: string, description: Payload for postback-type buttons }
phone: { type: string, description: Phone number for phone-type buttons (Facebook only) }
template:
type: object
description: Generic template for carousels (Instagram/Facebook only, ignored on Telegram).
properties:
type: { type: string, enum: [generic], description: Template type }
elements:
type: array
maxItems: 10
items:
type: object
required: [title]
properties:
title: { type: string, maxLength: 80, description: Element title (max 80 chars) }
subtitle: { type: string, description: Element subtitle }
imageUrl: { type: string, description: Element image URL }
buttons:
type: array
maxItems: 3
items:
type: object
properties:
type: { type: string, enum: [url, postback] }
title: { type: string, maxLength: 20 }
url: { type: string }
payload: { type: string }
interactive:
type: object
description: |
WhatsApp-only. Rich interactive payload for list messages, CTA URL
buttons, and Flow prompts. When set, takes priority over `buttons`
and `quickReplies`. The shape mirrors Meta's Cloud API `interactive`
object verbatim, so any payload that works against Meta directly
will also work here.
Use `buttons` / `quickReplies` for simple button replies
(WhatsApp's `interactive.type: "button"`) — the abstraction caps at
3 buttons and handles the auto-conversion for you. Use this field
only for `list`, `cta_url`, or `flow` messages.
Tap events come back via the `message.received` webhook with
`metadata.interactiveType` set to `list_reply` or `nfm_reply`.
required: [type, body, action]
properties:
type:
type: string
enum: [list, cta_url, flow]
description: Which interactive layout to render.
header:
type: object
description: Optional header shown above the body.
properties:
type: { type: string, enum: [text, image, video, document] }
text: { type: string, description: Required when header type is text. }
image: { type: object, properties: { link: { type: string } } }
video: { type: object, properties: { link: { type: string } } }
document: { type: object, properties: { link: { type: string } } }
body:
type: object
required: [text]
properties:
text: { type: string, description: Main body text. }
footer:
type: object
description: Optional footer shown below the action.
properties:
text: { type: string }
action:
oneOf:
- type: object
description: List action. `type` on the parent must be `list`.
required: [button, sections]
properties:
button:
type: string
description: CTA label that opens the list (max ~20 chars).
sections:
type: array
minItems: 1
maxItems: 10
description: 1-10 sections. Total rows across all sections cannot exceed 10.
items:
type: object
required: [rows]
properties:
title: { type: string, description: Optional section header (max 24 chars). }
rows:
type: array
minItems: 1
maxItems: 10
items:
type: object
required: [id, title]
properties:
id: { type: string, description: Identifier returned in the webhook as metadata.interactiveId (max 200 chars). }
title: { type: string, description: Row label (max 24 chars). }
description: { type: string, description: Optional description below the title (max 72 chars). }
- type: object
description: CTA URL action. `type` on the parent must be `cta_url`.
required: [name, parameters]
properties:
name: { type: string, enum: [cta_url] }
parameters:
type: object
required: [display_text, url]
properties:
display_text: { type: string, description: Button label (max 20 chars). }
url: { type: string, format: uri, description: Target URL opened when the user taps the button. }
- type: object
description: 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. }
replyMarkup:
type: object
description: Telegram-native keyboard markup. Ignored on other platforms.
properties:
type: { type: string, enum: [inline_keyboard, reply_keyboard], description: Keyboard type }
keyboard:
type: array
description: Array of rows, each row is an array of buttons
items:
type: array
items:
type: object
properties:
text: { type: string, description: Button text }
callbackData: { type: string, maxLength: 64, description: Callback data (inline_keyboard only, max 64 bytes) }
url: { type: string, description: URL to open (inline_keyboard only) }
oneTime: { type: boolean, default: true, description: Hide keyboard after use (reply_keyboard only) }
messagingType:
type: string
enum: [RESPONSE, UPDATE, MESSAGE_TAG]
description: Facebook messaging type. Required when using messageTag.
messageTag:
type: string
enum: [CONFIRMED_EVENT_UPDATE, POST_PURCHASE_UPDATE, ACCOUNT_UPDATE, HUMAN_AGENT]
description: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT.
replyTo:
type: string
description: Platform message ID to quote-reply to. For WhatsApp, pass the wamid (available in message.platformMessageId from webhooks). For Telegram, pass the Telegram message ID.
multipart/form-data:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
message: { type: string, description: Message text (optional when sending attachment) }
attachment:
type: string
format: binary
description: "File attachment (images, videos, documents). Supported formats: JPEG, PNG, GIF, MP4, AAC, WAV. Max 25MB."
quickReplies:
type: string
description: JSON string of quick replies array (same schema as application/json body)
buttons:
type: string
description: JSON string of buttons array (same schema as application/json body)
template:
type: string
description: JSON string of template object (same schema as application/json body)
replyMarkup:
type: string
description: JSON string of replyMarkup object (same schema as application/json body)
messagingType:
type: string
description: Messaging type (Facebook only). RESPONSE, UPDATE, or MESSAGE_TAG.
messageTag:
type: string
description: Message tag (requires messagingType MESSAGE_TAG)
replyTo:
type: string
description: Platform message ID to quote-reply to. For WhatsApp, pass the wamid (available in message.platformMessageId from webhooks). For Telegram, pass the Telegram message ID.
responses:
'200':
description: Message sent
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
messageId: { type: string, description: ID of the sent message (not returned for Reddit) }
conversationId: { type: string, nullable: true, description: Twitter conversation ID }
sentAt: { type: string, format: date-time, nullable: true, description: Bluesky sent timestamp }
message: { type: string, nullable: true, description: Success message (Reddit only) }
'400':
description: Bad request (e.g., attachment not supported for platform, validation error)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code:
type: string
enum: [PLATFORM_LIMITATION]
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/conversations/{conversationId}/messages/{messageId}:
patch:
operationId: editInboxMessage
summary: Edit message
description: |
Edit the text and/or reply markup of a previously sent Telegram message.
Only supported for Telegram. Returns 400 for other platforms.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The Telegram message ID to edit
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
text: { type: string, description: New message text }
replyMarkup:
type: object
description: New inline keyboard markup
properties:
type: { type: string, enum: [inline_keyboard] }
keyboard:
type: array
items:
type: array
items:
type: object
properties:
text: { type: string }
callbackData: { type: string }
url: { type: string }
responses:
'200':
description: Message edited
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
messageId: { type: integer }
'400':
description: Not supported or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
operationId: deleteInboxMessage
summary: Delete message
description: |
Delete a message from a conversation. Platform support varies:
- Telegram: Full delete (bot's own messages anytime, others if admin)
- X/Twitter: Full delete (own DM events only)
- Bluesky: Delete for self only (recipient still sees it)
- Reddit: Delete from sender's view only
- Facebook, Instagram, WhatsApp: Not supported (returns 400)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The platform message ID to delete
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
responses:
'200':
description: Message deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400':
description: Platform does not support deletion or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/typing:
post:
operationId: sendTypingIndicator
summary: Send typing indicator
description: |
Show a typing indicator in a conversation. Platform support:
- Facebook Messenger: Shows "Page is typing..." for 20 seconds
- Telegram: Shows "Bot is typing..." for 5 seconds
- All others: Returns 200 but no-op (platform doesn't support it)
Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Social account ID }
responses:
'200':
description: Typing indicator sent (or no-op on unsupported platforms)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions:
post:
operationId: addMessageReaction
summary: Add reaction
description: |
Add an emoji reaction to a message. Platform support:
- Telegram: Supports a subset of Unicode emoji reactions
- WhatsApp: Supports any standard emoji (one reaction per message per sender)
- All others: Returns 400 (not supported)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The platform message ID to react to
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, emoji]
properties:
accountId: { type: string, description: Social account ID }
emoji: { type: string, description: 'Emoji character (e.g. "👍", "❤️")', example: '👍' }
responses:
'200':
description: Reaction added
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400':
description: Platform does not support reactions or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
delete:
operationId: removeMessageReaction
summary: Remove reaction
description: |
Remove a reaction from a message. Platform support:
- Telegram: Send empty reaction array to clear
- WhatsApp: Send empty emoji to remove
- All others: Returns 400 (not supported)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The platform message ID
- name: accountId
in: query
required: true
schema: { type: string }
description: Social account ID
responses:
'200':
description: Reaction removed
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400':
description: Platform does not support reactions or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/media/upload-direct:
post:
operationId: uploadMediaDirect
summary: Upload media file
description: |
Upload a media file using API key authentication and get back a publicly accessible URL.
The URL can be used as attachmentUrl when sending inbox messages.
Files are stored in temporary storage and auto-delete after 7 days.
Maximum file size is 25MB.
Unlike /v1/media/upload (which uses upload tokens for end-user flows),
this endpoint uses standard Bearer token authentication for programmatic use.
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The file to upload (max 25MB)
contentType:
type: string
description: 'Override MIME type (e.g. "image/jpeg"). Auto-detected from file if not provided.'
responses:
'200':
description: File uploaded successfully
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Publicly accessible URL for the uploaded file }
filename: { type: string, description: Generated unique filename }
contentType: { type: string, description: MIME type of the file }
size: { type: integer, description: File size in bytes }
'400':
description: No file provided or file too large
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/messenger-menu:
get:
operationId: getMessengerMenu
summary: Get FB persistent menu
description: Get the persistent menu configuration for a Facebook Messenger account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Persistent menu configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
operationId: setMessengerMenu
summary: Set FB persistent menu
description: Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [persistent_menu]
properties:
persistent_menu:
type: array
description: Persistent menu configuration array (Meta format)
items: { type: object }
responses:
'200':
description: Menu set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
operationId: deleteMessengerMenu
description: Removes the persistent menu from Facebook Messenger conversations for this account.
summary: Delete FB persistent menu
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Menu deleted
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/instagram-ice-breakers:
get:
operationId: getInstagramIceBreakers
summary: Get IG ice breakers
description: Get the ice breaker configuration for an Instagram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breaker configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not an Instagram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
operationId: setInstagramIceBreakers
summary: Set IG ice breakers
description: Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ice_breakers]
properties:
ice_breakers:
type: array
maxItems: 4
items:
type: object
required: [question, payload]
properties:
question: { type: string, maxLength: 80 }
payload: { type: string }
responses:
'200':
description: Ice breakers set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
operationId: deleteInstagramIceBreakers
description: Removes the ice breaker questions from an Instagram account's Messenger experience.
summary: Delete IG ice breakers
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breakers deleted
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/telegram-commands:
get:
operationId: getTelegramCommands
summary: Get TG bot commands
description: Get the bot commands configuration for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Bot commands list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
command: { type: string }
description: { type: string }
'400':
description: Not a Telegram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
operationId: setTelegramCommands
summary: Set TG bot commands
description: Set bot commands for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [commands]
properties:
commands:
type: array
items:
type: object
required: [command, description]
properties:
command: { type: string, description: Bot command without leading slash }
description: { type: string, description: Command description }
responses:
'200':
description: Commands set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
operationId: deleteTelegramCommands
description: Clears all bot commands configured for a Telegram bot account.
summary: Delete TG bot commands
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Commands deleted
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/inbox/comments:
get:
operationId: listInboxComments
summary: List commented posts
description: Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts.
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] }
description: Filter by platform
- name: minComments
in: query
schema: { type: integer, minimum: 0 }
description: Minimum comment count
- name: since
in: query
schema: { type: string, format: date-time }
description: Posts created after this date
- name: sortBy
in: query
schema: { type: string, enum: [date, comments], default: date }
description: Sort field
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
- name: cursor
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated posts with comments
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string }
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
content: { type: string }
picture: { type: string, nullable: true }
permalink: { type: string, nullable: true }
createdTime: { type: string, format: date-time }
commentCount: { type: integer }
likeCount: { type: integer }
cid: { type: string, nullable: true, description: Bluesky content identifier }
subreddit: { type: string, nullable: true, description: Reddit subreddit name }
pagination:
type: object
properties:
hasMore: { type: boolean }
nextCursor: { type: string, nullable: true }
meta:
type: object
properties:
accountsQueried: { type: integer }
accountsFailed: { type: integer }
failedAccounts:
type: array
items:
type: object
properties:
accountId: { type: string }
accountUsername: { type: string, nullable: true }
platform: { type: string }
error: { type: string }
code: { type: string, nullable: true, description: Error code if available }
retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
lastUpdated: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/comments/{postId}:
get:
operationId: getInboxPostComments
summary: Get post comments
description: Fetch comments for a specific post. Requires accountId query parameter.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
- name: subreddit
in: query
schema: { type: string }
description: (Reddit only) Subreddit name
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
description: Maximum number of comments to return
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor
- name: commentId
in: query
schema: { type: string }
description: (Reddit only) Get replies to a specific comment
responses:
'200':
description: Comments for the post
content:
application/json:
schema:
type: object
properties:
status: { type: string }
comments:
type: array
items:
type: object
properties:
id: { type: string }
message: { type: string }
createdTime: { type: string, format: date-time }
from:
type: object
properties:
id: { type: string }
name: { type: string }
username: { type: string }
picture: { type: string, nullable: true }
isOwner: { type: boolean }
verifiedType:
type: string
nullable: true
enum: [blue, government, business, none]
description: X/Twitter verified badge type. Only present for Twitter/X comments.
likeCount: { type: integer }
replyCount: { type: integer }
platform: { type: string, description: The platform this comment is from }
url:
type: string
nullable: true
description: Direct link to the comment on the platform (if available)
replies:
type: array
items: { type: object }
canReply: { type: boolean }
canDelete: { type: boolean }
canHide: { type: boolean, description: Whether this comment can be hidden (Facebook, Instagram, Threads) }
canLike: { type: boolean, description: Whether this comment can be liked (Facebook, Twitter/X, Bluesky, Reddit) }
isHidden: { type: boolean, description: Whether the comment is currently hidden }
isLiked: { type: boolean, description: Whether the current user has liked this comment }
likeUri: { type: string, nullable: true, description: Bluesky like URI for unliking }
cid: { type: string, nullable: true, description: Bluesky content identifier }
parentId: { type: string, nullable: true, description: Parent comment ID for nested replies }
rootUri: { type: string, nullable: true, description: Bluesky root post URI }
rootCid: { type: string, nullable: true, description: Bluesky root post CID }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: string, nullable: true }
meta:
type: object
properties:
platform: { type: string }
postId: { type: string }
accountId: { type: string }
subreddit: { type: string, nullable: true, description: (Reddit only) Subreddit name }
lastUpdated: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
post:
operationId: replyToInboxPost
summary: Reply to comment
description: Post a reply to a post or specific comment. Requires accountId in request body.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, message]
properties:
accountId: { type: string }
message: { type: string }
commentId: { type: string, description: Reply to specific comment (optional) }
parentCid: { type: string, description: (Bluesky only) Parent content identifier }
rootUri: { type: string, description: (Bluesky only) Root post URI }
rootCid: { type: string, description: (Bluesky only) Root post CID }
responses:
'200':
description: Reply posted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
commentId: { type: string }
isReply: { type: boolean }
cid: { type: string, nullable: true, description: Bluesky CID }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
operationId: deleteInboxComment
summary: Delete comment
description: |
Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn.
Requires accountId and commentId query parameters.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
- name: commentId
in: query
required: true
schema: { type: string }
responses:
'200':
description: Comment deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
message: { type: string }
'400':
description: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/comments/{postId}/{commentId}/hide:
post:
operationId: hideInboxComment
summary: Hide comment
description: |
Hide a comment on a post. Supported by Facebook, Instagram, Threads, and X/Twitter.
Hidden comments are only visible to the commenter and page admin.
For X/Twitter, the reply must belong to a conversation started by the authenticated user.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: The social account ID }
responses:
'200':
description: Comment hidden
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
hidden: { type: boolean }
platform: { type: string }
'400':
description: Platform does not support hiding comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
operationId: unhideInboxComment
summary: Unhide comment
description: |
Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
responses:
'200':
description: Comment unhidden
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
hidden: { type: boolean }
platform: { type: string }
'400':
description: Platform does not support unhiding comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/comments/{postId}/{commentId}/like:
post:
operationId: likeInboxComment
summary: Like comment
description: |
Like or upvote a comment on a post. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit.
For Bluesky, the cid (content identifier) is required in the request body.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: The social account ID }
cid: { type: string, description: (Bluesky only) Content identifier for the comment }
responses:
'200':
description: Comment liked
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
liked: { type: boolean }
likeUri: { type: string, description: (Bluesky only) URI to use for unliking }
platform: { type: string }
'400':
description: Platform does not support liking comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
operationId: unlikeInboxComment
summary: Unlike comment
description: |
Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit.
For Bluesky, the likeUri query parameter is required.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
- name: likeUri
in: query
schema: { type: string }
description: (Bluesky only) The like URI returned when liking
responses:
'200':
description: Comment unliked
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
liked: { type: boolean }
platform: { type: string }
'400':
description: Platform does not support unliking comments
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/comments/{postId}/{commentId}/private-reply:
post:
operationId: sendPrivateReplyToComment
summary: Send private reply
description: Send a private message to the author of a comment. Supported on Instagram and Facebook only. One reply per comment, must be sent within 7 days, text only.
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
example:
accountId: "507f1f77bcf86cd799439011"
message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question."
responses:
'200':
description: Private reply sent successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
messageId:
type: string
description: The ID of the sent message
commentId:
type: string
description: The comment ID that was replied to
platform:
type: string
enum: [instagram, facebook]
example: instagram
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: string
code:
type: string
enum: [PLATFORM_LIMITATION]
examples:
platformNotSupported:
summary: Platform not supported
value:
error: "Private replies to comments are only supported on Instagram and Facebook."
code: "PLATFORM_LIMITATION"
alreadyReplied:
summary: Already sent a private reply
value:
error: "A private reply has already been sent to this comment, or the 7-day reply window has expired. Only one private reply per comment is allowed within 7 days."
commentTooOld:
summary: Comment older than 7 days
value:
error: "The comment is older than 7 days. Private replies can only be sent within 7 days of the comment being posted."
missingMessage:
summary: Missing message
value:
error: "message is required and must be a non-empty string"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account not found
/v1/twitter/retweet:
post:
operationId: retweetPost
summary: Retweet a post
description: |
Retweet (repost) a tweet by ID.
Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The social account ID }
tweetId: { type: string, description: The ID of the tweet to retweet }
responses:
'200':
description: Tweet retweeted
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
delete:
operationId: undoRetweet
summary: Undo retweet
description: |
Undo a retweet (un-repost a tweet).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the original tweet to un-retweet
responses:
'200':
description: Retweet undone
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/twitter/bookmark:
post:
operationId: bookmarkPost
summary: Bookmark a tweet
description: |
Bookmark a tweet by ID.
Requires the bookmark.write OAuth scope.
Rate limit: 50 requests per 15-min window.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The social account ID }
tweetId: { type: string, description: The ID of the tweet to bookmark }
responses:
'200':
description: Tweet bookmarked
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
delete:
operationId: removeBookmark
summary: Remove bookmark
description: |
Remove a bookmark from a tweet.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the tweet to unbookmark
responses:
'200':
description: Bookmark removed
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/twitter/follow:
post:
operationId: followUser
summary: Follow a user
description: |
Follow a user on X/Twitter.
Requires the follows.write OAuth scope.
For protected accounts, a follow request is sent instead (pending_follow will be true).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, targetUserId]
properties:
accountId: { type: string, description: The social account ID }
targetUserId: { type: string, description: The Twitter ID of the user to follow }
responses:
'200':
description: User followed or follow request sent
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
targetUserId: { type: string }
following: { type: boolean }
pending_follow: { type: boolean, description: True if the target account is protected and a follow request was sent }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
delete:
operationId: unfollowUser
summary: Unfollow a user
description: |
Unfollow a user on X/Twitter.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: targetUserId
in: query
required: true
schema: { type: string }
description: The Twitter ID of the user to unfollow
responses:
'200':
description: User unfollowed
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
targetUserId: { type: string }
following: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/inbox/reviews:
get:
operationId: listInboxReviews
summary: List reviews
description: |
Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options.
Supported platforms: Facebook, Google Business.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string, enum: [facebook, googlebusiness] }
- name: minRating
in: query
schema: { type: integer, minimum: 1, maximum: 5 }
- name: maxRating
in: query
schema: { type: integer, minimum: 1, maximum: 5 }
- name: hasReply
in: query
schema: { type: boolean }
description: Filter by reply status
- name: sortBy
in: query
schema: { type: string, enum: [date, rating], default: date }
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
- name: cursor
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
description: Filter by specific social account ID
responses:
'200':
description: Aggregated reviews
content:
application/json:
schema:
type: object
properties:
status: { type: string }
data:
type: array
items:
type: object
properties:
id: { type: string }
platform: { type: string }
accountId: { type: string }
accountUsername: { type: string }
reviewer:
type: object
properties:
id: { type: string, nullable: true }
name: { type: string }
profileImage: { type: string, nullable: true }
rating: { type: integer }
text: { type: string }
created: { type: string, format: date-time }
hasReply: { type: boolean }
reply:
type: object
nullable: true
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
reviewUrl: { type: string, nullable: true }
pagination:
type: object
properties:
hasMore: { type: boolean }
nextCursor: { type: string, nullable: true }
meta:
type: object
properties:
accountsQueried: { type: integer }
accountsFailed: { type: integer }
failedAccounts:
type: array
items:
type: object
properties:
accountId: { type: string }
accountUsername: { type: string, nullable: true }
platform: { type: string }
error: { type: string }
code: { type: string, nullable: true, description: Error code if available }
retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
lastUpdated: { type: string, format: date-time }
summary:
type: object
properties:
totalReviews: { type: integer }
averageRating: { type: number, nullable: true }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
/v1/inbox/reviews/{reviewId}/reply:
post:
operationId: replyToInboxReview
summary: Reply to review
description: Post a reply to a review. Requires accountId in request body.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: reviewId
in: path
required: true
schema: { type: string }
description: Review ID (URL-encoded for Google Business)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, message]
properties:
accountId: { type: string }
message: { type: string }
responses:
'200':
description: Reply posted
content:
application/json:
schema:
type: object
properties:
status: { type: string }
reply:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
platform: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
operationId: deleteInboxReviewReply
summary: Delete review reply
description: Delete a reply to a review (Google Business only). Requires accountId in request body.
tags: [Reviews]
security: [{ bearerAuth: [] }]
parameters:
- name: reviewId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
responses:
'200':
description: Reply deleted
content:
application/json:
schema:
type: object
properties:
status: { type: string }
message: { type: string }
platform: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
# ──────────────────────────────────────────────────────────────────────────
# WHATSAPP-SPECIFIC ENDPOINTS
# Templates, business profile, phone numbers: ACTIVE (no cross-platform equivalent)
# ──────────────────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────────────────
# TEMPLATES
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/templates:
get:
operationId: getWhatsAppTemplates
tags: [WhatsApp]
summary: List templates
description: |
List all message templates for the WhatsApp Business Account (WABA) associated with the given account.
Templates are fetched directly from the WhatsApp Cloud API.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Templates retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
templates:
type: array
items:
type: object
properties:
id: { type: string, description: WhatsApp template ID }
name: { type: string }
status: { type: string, enum: [APPROVED, PENDING, REJECTED] }
category: { type: string, enum: [AUTHENTICATION, MARKETING, UTILITY] }
language: { type: string }
components:
type: array
items:
type: object
'400': { description: accountId is required or WABA ID not found }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
operationId: createWhatsAppTemplate
tags: [WhatsApp]
summary: Create template
description: |
Create a new message template. Supports two modes:
Custom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h).
Library template: Provide library_template_name instead of components to use a pre-built template
from Meta's template library. Library templates are pre-approved (no review wait). You can optionally
customize parameters and buttons via library_template_body_inputs and library_template_button_inputs.
Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
- name
- category
- language
properties:
accountId:
type: string
description: WhatsApp social account ID
name:
type: string
pattern: "^[a-z][a-z0-9_]*$"
description: Template name (lowercase, letters/numbers/underscores, must start with a letter)
category:
type: string
enum: [AUTHENTICATION, MARKETING, UTILITY]
description: Template category
language:
type: string
description: Template language code (e.g., en_US)
components:
type: array
description: "Template components (header, body, footer, buttons). Required for custom templates, omit when using library_template_name."
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateComponent'
library_template_name:
type: string
description: |
Name of a pre-built template from Meta's template library (e.g., "appointment_reminder",
"auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved
by Meta with no review wait. Omit components when using this field.
library_template_body_inputs:
type: object
description: |
Optional body customizations for library templates. Available options depend on the
template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation,
add_track_package_link, code_expiration_minutes).
library_template_button_inputs:
type: array
description: |
Optional button customizations for library templates. Each item specifies button type
and configuration (e.g., URL, phone number, quick reply).
items:
type: object
properties:
type:
type: string
enum: [quick_reply, url, phone_number]
url:
type: object
properties:
base_url: { type: string }
phone_number:
type: string
examples:
custom:
summary: Custom template (requires review)
value:
accountId: "507f1f77bcf86cd799439011"
name: "order_confirmation"
category: "UTILITY"
language: "en_US"
components:
- type: "header"
format: "image"
example:
header_handle: ["https://example.com/header.jpg"]
- type: "body"
text: "Your order {{1}} has been confirmed. Expected delivery: {{2}}"
example:
body_text: [["ORD-12345", "March 31"]]
- type: "footer"
text: "Thank you for your purchase"
- type: "buttons"
buttons:
- type: "quick_reply"
text: "Track Order"
library:
summary: Library template (pre-approved, no review)
value:
accountId: "507f1f77bcf86cd799439011"
name: "my_appointment_reminder"
category: "UTILITY"
language: "en_US"
library_template_name: "appointment_reminder"
library_template_button_inputs:
- type: "url"
url:
base_url: "https://myapp.com/appointments/{{1}}"
responses:
'200':
description: Template created (pre-approved for library templates, pending review for custom)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string, description: "APPROVED for library templates, PENDING for custom" }
category: { type: string }
language: { type: string }
'400': { description: Validation error (invalid name format, missing fields, invalid category) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/templates/{templateName}:
get:
operationId: getWhatsAppTemplate
tags: [WhatsApp]
summary: Get template
description: |
Retrieve a single message template by name.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Template retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string }
category: { type: string }
language: { type: string }
components:
type: array
items:
type: object
'400': { description: accountId is required }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
operationId: updateWhatsAppTemplate
tags: [WhatsApp]
summary: Update template
description: |
Update a message template's components. Only certain fields can be updated depending on
the template's current approval state. Approved templates can only have components updated.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
- components
properties:
accountId:
type: string
description: WhatsApp social account ID
components:
type: array
description: Updated template components
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateComponent'
example:
accountId: "507f1f77bcf86cd799439011"
components:
- type: "body"
text: "Updated: Your order {{1}} is confirmed. Delivery by {{2}}"
example:
body_text: [["ORD-12345", "April 1"]]
- type: "buttons"
buttons:
- type: "quick_reply"
text: "Track Order"
responses:
'200':
description: Template updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
template:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string }
'400': { description: Validation error (missing fields) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteWhatsAppTemplate
tags: [WhatsApp]
summary: Delete template
description: |
Permanently delete a message template by name.
security:
- bearerAuth: []
parameters:
- name: templateName
in: path
required: true
description: Template name
schema:
type: string
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Template deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Template \"order_confirmation\" deleted successfully"
'400': { description: accountId or template name is required }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ──────────────────────────────────────────────────────────────────────────
# BUSINESS PROFILE
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/business-profile:
get:
operationId: getWhatsAppBusinessProfile
tags: [WhatsApp]
summary: Get business profile
description: |
Retrieve the WhatsApp Business profile for the account (about, address, description, email, websites, etc.).
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Business profile retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
businessProfile:
type: object
properties:
about: { type: string, description: Short description (max 139 chars) }
address: { type: string }
description: { type: string, description: Full description (max 512 chars) }
email: { type: string }
profilePictureUrl: { type: string, format: uri }
websites:
type: array
items: { type: string }
maxItems: 2
vertical: { type: string, description: Business category }
'400': { description: accountId is required or phone number ID not found }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
operationId: updateWhatsAppBusinessProfile
tags: [WhatsApp]
summary: Update business profile
description: |
Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated.
Constraints: about max 139 chars, description max 512 chars, max 2 websites.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
properties:
accountId:
type: string
description: WhatsApp social account ID
about:
type: string
maxLength: 139
description: Short business description (max 139 characters)
address:
type: string
description: Business address
description:
type: string
maxLength: 512
description: Full business description (max 512 characters)
email:
type: string
format: email
description: Business email
websites:
type: array
maxItems: 2
items: { type: string, format: uri }
description: Business websites (max 2)
vertical:
type: string
description: Business category (e.g., RETAIL, ENTERTAINMENT, etc.)
profilePictureHandle:
type: string
description: Handle from resumable upload for profile picture
example:
accountId: "507f1f77bcf86cd799439011"
about: "We help businesses grow"
description: "Premium business solutions for startups and enterprises"
email: "hello@example.com"
websites: ["https://example.com"]
responses:
'200':
description: Business profile updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Business profile updated successfully"
'400': { description: Validation error (field too long, too many websites, etc.) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/photo:
post:
operationId: uploadWhatsAppProfilePhoto
tags: [WhatsApp]
summary: Upload profile picture
description: |
Upload a new profile picture for the WhatsApp Business Profile.
Uses Meta's resumable upload API under the hood: creates an upload session,
uploads the image bytes, then updates the business profile with the resulting handle.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [accountId, file]
properties:
accountId:
type: string
description: WhatsApp social account ID
file:
type: string
format: binary
description: Image file (JPEG or PNG, max 5MB, recommended 640x640)
responses:
'200':
description: Profile picture updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { description: Invalid file type, file too large, or missing parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/display-name:
get:
operationId: getWhatsAppDisplayName
tags: [WhatsApp]
summary: Get display name status
description: |
Fetch the current display name and its Meta review status for a WhatsApp Business account.
Display name changes require Meta approval and can take 1-3 business days.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp social account ID
schema:
type: string
responses:
'200':
description: Display name info retrieved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
displayName:
type: object
properties:
name: { type: string, description: Current verified display name }
status:
type: string
enum: [APPROVED, PENDING_REVIEW, DECLINED, NONE]
description: Meta review status for the display name
phoneNumber: { type: string, description: Display phone number }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
operationId: updateWhatsAppDisplayName
tags: [WhatsApp]
summary: Request display name change
description: |
Submit a display name change request for the WhatsApp Business account.
The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business).
Changes require Meta review and approval, which typically takes 1-3 business days.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, displayName]
properties:
accountId:
type: string
description: WhatsApp social account ID
displayName:
type: string
minLength: 3
maxLength: 512
description: New display name (must follow WhatsApp naming guidelines)
example:
accountId: "507f1f77bcf86cd799439011"
displayName: "My Business Name"
responses:
'200':
description: Display name change submitted for review
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
displayName:
type: object
properties:
name: { type: string }
status: { type: string, enum: [PENDING_REVIEW] }
'400': { description: Invalid display name (too short, too long, or missing) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
# ──────────────────────────────────────────────────────────────────────────
# PHONE NUMBERS
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/phone-numbers:
get:
operationId: getWhatsAppPhoneNumbers
tags: [WhatsApp Phone Numbers]
summary: List phone numbers
description: |
List all WhatsApp phone numbers purchased by the authenticated user.
By default, released numbers are excluded.
security:
- bearerAuth: []
parameters:
- name: status
in: query
required: false
description: Filter by status (by default excludes released numbers)
schema:
type: string
enum: [provisioning, 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, provisioning, active, suspended, releasing, released] }
profileId: { type: object }
provisionedAt: { type: string, format: date-time }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/purchase:
post:
operationId: purchaseWhatsAppPhoneNumber
tags: [WhatsApp Phone Numbers]
summary: Purchase phone number
description: |
Initiate purchasing a WhatsApp phone number. Payment-first flow: the user does not pick
a specific number. The system either creates a Stripe Checkout Session (first number)
or increments the existing subscription quantity and provisions inline (subsequent numbers).
Requires a paid plan. The maximum number of phone numbers is determined by the user's plan.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- profileId
properties:
profileId:
type: string
description: Profile to associate the number with
example:
profileId: "507f1f77bcf86cd799439011"
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 }
'400': { description: Plan limit reached or profileId required }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: A paid plan is required }
/v1/whatsapp/phone-numbers/{phoneNumberId}:
get:
operationId: getWhatsAppPhoneNumber
tags: [WhatsApp Phone Numbers]
summary: Get phone number
description: |
Retrieve the current status of a purchased phone number. Used to poll for
Meta pre-verification completion after purchase.
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, provisioning, active, suspended, releasing, released] }
country: { type: string }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
provisionedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: releaseWhatsAppPhoneNumber
tags: [WhatsApp 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: phoneNumberId
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number released successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, description: "\"released\"" }
releasedAt: { type: string, format: date-time }
'400': { description: Phone number is already released or being released }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── WhatsApp Group Chats (platform groups, not contact groups) ──
/v1/whatsapp/wa-groups:
get:
operationId: listWhatsAppGroupChats
tags: [WhatsApp]
summary: List active groups
description: |
List active WhatsApp group chats for a business phone number.
These are actual WhatsApp group conversations on the platform.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: limit, in: query, schema: { type: integer, default: 25, maximum: 1024 }, description: Max groups to return }
- { name: after, in: query, schema: { type: string }, description: Pagination cursor }
responses:
'200':
description: List of active groups
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: object
properties:
id: { type: string, description: Group ID }
subject: { type: string, description: Group name }
createdAt: { type: string, description: Group creation timestamp }
paging:
type: object
properties:
cursors:
type: object
properties:
after: { type: string }
before: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createWhatsAppGroupChat
tags: [WhatsApp]
summary: Create group
description: |
Create a new WhatsApp group chat. Returns the group ID and optionally an invite link.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, subject]
properties:
accountId: { type: string, description: WhatsApp social account ID }
subject: { type: string, maxLength: 128, description: Group name (max 128 characters) }
description: { type: string, maxLength: 2048, description: Group description (max 2048 characters) }
joinApprovalMode:
type: string
enum: [approval_required, auto_approve]
description: Whether users need approval to join via invite link
responses:
'201':
description: Group created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
groupId: { type: string }
inviteLink: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}:
get:
operationId: getWhatsAppGroupChat
tags: [WhatsApp]
summary: Get group info
description: |
Retrieve metadata about a WhatsApp group including subject, description,
participants, and settings.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Group info
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
id: { type: string }
subject: { type: string }
description: { type: string }
joinApprovalMode: { type: string }
participants:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
admin: { type: string }
participantCount: { type: integer }
createdAt: { type: integer, description: UNIX timestamp }
isSuspended: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
post:
operationId: updateWhatsAppGroupChat
tags: [WhatsApp]
summary: Update group settings
description: |
Update the subject, description, or join approval mode of a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
subject: { type: string, maxLength: 128 }
description: { type: string, maxLength: 2048 }
joinApprovalMode: { type: string, enum: [approval_required, auto_approve] }
responses:
'200':
description: Group updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteWhatsAppGroupChat
tags: [WhatsApp]
summary: Delete group
description: |
Delete a WhatsApp group and remove all participants.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Group deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/whatsapp/wa-groups/{groupId}/participants:
post:
operationId: addWhatsAppGroupParticipants
tags: [WhatsApp]
summary: Add participants
description: |
Add participants to a WhatsApp group. Maximum 8 participants per request.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
maxItems: 8
items: { type: string }
description: Phone numbers in E.164 format (max 8)
responses:
'200':
description: Participants added
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
operationId: removeWhatsAppGroupParticipants
tags: [WhatsApp]
summary: Remove participants
description: |
Remove participants from a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to remove
responses:
'200':
description: Participants removed
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/invite-link:
post:
operationId: createWhatsAppGroupInviteLink
tags: [WhatsApp]
summary: Create invite link
description: |
Create a new invite link for a WhatsApp group. The previous link is revoked.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Invite link created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
inviteLink: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/join-requests:
get:
operationId: listWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: List join requests
description: |
List pending join requests for a WhatsApp group (only for groups with approval_required mode).
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Join requests
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
joinRequests:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
timestamp: { type: integer, description: UNIX timestamp of request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: approveWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Approve join requests
description: |
Approve pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to approve
responses:
'200':
description: Requests approved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
operationId: rejectWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Reject join requests
description: |
Reject pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to reject
responses:
'200':
description: Requests rejected
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
# ─── WhatsApp Flows ───────────────────────────────────────────────
/v1/whatsapp/flows:
get:
operationId: listWhatsAppFlows
tags: [WhatsApp Flows]
summary: List flows
description: |
List all WhatsApp Flows for the Business Account (WABA) associated with the given account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Flows retrieved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
flows:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string, enum: [DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED] }
categories:
type: array
items: { type: string }
validation_errors:
type: array
items: { type: object }
'400': { description: WABA ID not found on account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
operationId: createWhatsAppFlow
tags: [WhatsApp Flows]
summary: Create flow
description: |
Create a new WhatsApp Flow in DRAFT status. Optionally clone an existing flow.
After creating, upload a Flow JSON definition, then publish to make it sendable.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, categories]
properties:
accountId: { type: string, description: WhatsApp social account ID }
name: { type: string, maxLength: 128, description: Flow display name }
categories:
type: array
minItems: 1
items:
type: string
enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
description: Flow categories
cloneFlowId: { type: string, description: "Optional: ID of an existing flow to clone" }
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 }
'400': { description: Validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}:
get:
operationId: getWhatsAppFlow
tags: [WhatsApp Flows]
summary: Get flow
description: |
Get details for a specific flow, including status, categories, validation errors, and preview URL.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated fields to return (default: id,name,status,categories,validation_errors,json_version,preview,data_api_version,endpoint_uri)" }
responses:
'200':
description: Flow details
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
flow:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string }
categories:
type: array
items: { type: string }
validation_errors:
type: array
items: { type: object }
json_version: { type: string }
preview:
type: object
properties:
preview_url: { type: string }
expires_at: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
patch:
operationId: updateWhatsAppFlow
tags: [WhatsApp Flows]
summary: Update flow
description: |
Update metadata (name, categories) of a DRAFT flow. Published flows are immutable.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp social account ID }
name: { type: string, maxLength: 128, description: New flow name }
categories:
type: array
minItems: 1
items:
type: string
enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
responses:
'200':
description: Flow updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: "At least one of name or categories is required, or flow is not in DRAFT status" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account or flow not found }
delete:
operationId: deleteWhatsAppFlow
tags: [WhatsApp Flows]
summary: Delete flow
description: |
Delete a DRAFT flow. This is irreversible. Only flows in DRAFT status can be deleted.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Flow deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in DRAFT status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account or flow not found }
/v1/whatsapp/flows/{flowId}/json:
get:
operationId: getWhatsAppFlowJson
tags: [WhatsApp Flows]
summary: Get flow JSON asset
description: |
Get the flow JSON asset metadata, including a temporary download URL for the Flow JSON file.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
responses:
'200':
description: Flow JSON asset
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
assets:
type: array
items:
type: object
properties:
name: { type: string, example: flow.json }
asset_type: { type: string, example: FLOW_JSON }
download_url: { type: string, description: Temporary URL to download the flow JSON }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
put:
operationId: uploadWhatsAppFlowJson
tags: [WhatsApp Flows]
summary: Upload flow JSON
description: |
Upload or update the Flow JSON for a DRAFT flow. The Flow JSON defines all screens,
components (text inputs, dropdowns, date pickers, etc.), and navigation.
Meta validates the JSON on upload and returns any validation errors.
See: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, flow_json]
properties:
accountId: { type: string, description: WhatsApp social account ID }
flow_json:
description: "The Flow JSON content. Pass as a JSON object or a JSON string."
oneOf:
- type: object
- type: string
examples:
simple_form:
summary: Simple lead capture form
value:
accountId: "507f1f77bcf86cd799439011"
flow_json:
version: "6.0"
screens:
- id: "LEAD_FORM"
title: "Get a Quote"
terminal: true
success: true
layout:
type: "SingleColumnLayout"
children:
- type: "TextInput"
name: "full_name"
label: "Full Name"
required: true
input-type: "text"
- type: "TextInput"
name: "email"
label: "Email"
required: true
input-type: "email"
- type: "Footer"
label: "Submit"
on-click-action:
name: "complete"
payload:
full_name: "${form.full_name}"
email: "${form.email}"
responses:
'200':
description: Flow JSON uploaded
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
validation_errors:
type: array
description: "Empty array if valid; otherwise, contains validation error details from Meta"
items:
type: object
properties:
error: { type: string }
error_type: { type: string }
message: { type: string }
line_start: { type: integer }
line_end: { type: integer }
column_start: { type: integer }
column_end: { type: integer }
'400': { description: Invalid JSON or flow is not in DRAFT status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/publish:
post:
operationId: publishWhatsAppFlow
tags: [WhatsApp Flows]
summary: Publish flow
description: |
Publish a DRAFT flow. This is irreversible. Once published, the flow and its JSON
become immutable and the flow can be sent to users. To update a published flow,
create a new flow (optionally cloning this one via cloneFlowId).
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp social account ID }
responses:
'200':
description: Flow published
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in DRAFT status or has validation errors }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/deprecate:
post:
operationId: deprecateWhatsAppFlow
tags: [WhatsApp Flows]
summary: Deprecate flow
description: |
Deprecate a PUBLISHED flow. This is irreversible. Deprecated flows cannot be sent
or opened, but existing active sessions may continue until they complete.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp social account ID }
responses:
'200':
description: Flow deprecated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in PUBLISHED status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/send:
post:
operationId: sendWhatsAppFlowMessage
tags: [WhatsApp Flows]
summary: Send flow message
description: |
Send a published flow as an interactive message with a CTA button.
When the recipient taps the button, the flow opens natively in WhatsApp.
Flow responses are received via webhooks.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, to, flow_id, flow_cta, body]
properties:
accountId: { type: string, description: WhatsApp social account ID }
to: { type: string, description: "Recipient phone number (E.164 format, e.g. +1234567890)" }
flow_id: { type: string, description: Published flow ID }
flow_cta: { type: string, maxLength: 20, description: "CTA button text (e.g. 'Book Now', 'Sign Up')" }
flow_action:
type: string
enum: [navigate, data_exchange]
default: navigate
description: "Action type: navigate opens a screen directly, data_exchange hits your endpoint first"
flow_token: { type: string, maxLength: 200, description: "Unique token to correlate responses. Auto-generated UUID if omitted." }
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 }
# ─── Contacts ─────────────────────────────────────────────────────
/v1/contacts:
get:
operationId: listContacts
summary: List contacts
description: List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search.
tags: [Contacts]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: search, in: query, schema: { type: string } }
- { name: tag, in: query, schema: { type: string } }
- { name: platform, in: query, schema: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] } }
- { name: isSubscribed, in: query, schema: { type: string, enum: ['true', 'false'] } }
- { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Contacts list with pagination and filter metadata
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contacts:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
lastMessageSentAt: { type: string, format: date-time }
lastMessageReceivedAt: { type: string, format: date-time }
messagesSentCount: { type: integer }
messagesReceivedCount: { type: integer }
customFields: { type: object }
notes: { type: string }
createdAt: { type: string, format: date-time }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
filters:
type: object
properties:
tags: { type: array, items: { type: string } }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createContact
summary: Create contact
description: Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier.
tags: [Contacts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name]
properties:
profileId: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean, default: true }
notes: { type: string }
accountId: { type: string, description: Optional. Creates a channel if provided with platform + platformIdentifier }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
responses:
'200':
description: Contact created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
customFields: { type: object }
notes: { type: string }
createdAt: { type: string, format: date-time }
channel:
type: object
description: Created when accountId, platform, and platformIdentifier are provided
properties:
id: { type: string }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
warning: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Duplicate contact }
/v1/contacts/{contactId}:
get:
operationId: getContact
summary: Get contact
description: Returns a contact with all associated messaging channels.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Contact with channels
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
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 }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
operationId: updateContact
summary: Update contact
description: Update one or more fields on a contact. Only provided fields are changed.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
notes: { type: string }
responses:
'200':
description: Contact updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
contact:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
company: { type: string }
avatarUrl: { type: string }
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
isBlocked: { type: boolean }
notes: { type: string }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteContact
summary: Delete contact
description: Permanently deletes a contact and all associated channels.
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Contact deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/contacts/{contactId}/channels:
get:
operationId: getContactChannels
summary: List channels for a contact
description: Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp).
tags: [Contacts]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: List of contact channels
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
channels:
type: array
items:
type: object
properties:
id: { type: string }
accountId: { type: string }
platform: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
isSubscribed: { type: boolean }
conversationId: { type: string }
metadata: { type: object }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/contacts/bulk:
post:
operationId: bulkCreateContacts
summary: Bulk create contacts
description: Import up to 1000 contacts at a time. Skips duplicates.
tags: [Contacts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, contacts]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string }
contacts:
type: array
maxItems: 1000
items:
type: object
required: [name, platformIdentifier]
properties:
name: { type: string }
platformIdentifier: { type: string }
displayIdentifier: { type: string }
email: { type: string }
company: { type: string }
tags: { type: array, items: { type: string } }
responses:
'200':
description: Bulk import results
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
created: { type: integer }
skipped: { type: integer }
errors: { type: array, items: { type: object } }
total: { type: integer }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/contacts/{contactId}/fields/{slug}:
put:
operationId: setContactFieldValue
summary: Set custom field value
description: Set or overwrite a custom field value on a contact. The value type must match the field definition.
tags: [Custom Fields]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
- { name: slug, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value: { description: Field value (type depends on field definition) }
responses:
'200': { description: Field value set }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: clearContactFieldValue
summary: Clear custom field value
description: Remove a custom field value from a contact. The field definition is not affected.
tags: [Custom Fields]
parameters:
- { name: contactId, in: path, required: true, schema: { type: string } }
- { name: slug, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Field value cleared }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Custom Fields ────────────────────────────────────────────────
/v1/custom-fields:
get:
operationId: listCustomFields
summary: List custom field definitions
description: Returns all custom field definitions. Optionally filter by profile.
tags: [Custom Fields]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
responses:
'200':
description: List of custom field definitions
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
fields:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createCustomField
summary: Create custom field
description: Create a new custom field definition. Supported types are text, number, date, boolean, and select.
tags: [Custom Fields]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, type]
properties:
profileId: { type: string }
name: { type: string }
slug: { type: string, description: Auto-generated from name if not provided }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string }, description: Required for select type }
responses:
'200':
description: Custom field created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Duplicate slug }
/v1/custom-fields/{fieldId}:
patch:
operationId: updateCustomField
summary: Update custom field
description: Update a custom field definition. The field type cannot be changed after creation.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
options: { type: array, items: { type: string } }
responses:
'200':
description: Custom field updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string }
options: { type: array, items: { type: string } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteCustomField
summary: Delete custom field
description: Delete a custom field definition and remove its values from all contacts.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Custom field deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Broadcasts ───────────────────────────────────────────────────
/v1/broadcasts:
get:
operationId: listBroadcasts
summary: List broadcasts
description: Returns broadcasts with delivery stats. Filter by status, platform, or profile.
tags: [Broadcasts]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } }
- { name: platform, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Broadcasts list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcasts:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
messagePreview: { type: string, description: Template name or message text snippet }
scheduledAt: { type: string, format: date-time }
startedAt: { type: string, format: date-time }
completedAt: { type: string, format: date-time }
recipientCount: { type: integer }
sentCount: { type: integer }
deliveredCount: { type: integer }
readCount: { type: integer }
failedCount: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createBroadcast
summary: Create broadcast draft
description: Create a broadcast in draft status. Add recipients and then send or schedule it.
tags: [Broadcasts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
name: { type: string }
description: { type: string }
message:
type: object
properties:
text: { type: string }
attachments: { type: array, items: { type: object, properties: { type: { type: string }, url: { type: string }, filename: { type: string } } } }
template:
type: object
description: WhatsApp template (required when platform is whatsapp)
properties:
name: { type: string }
language: { type: string }
components: { type: array }
segmentFilters:
type: object
properties:
tags: { type: array, items: { type: string } }
isSubscribed: { type: boolean }
responses:
'200':
description: Broadcast created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
status: { type: string }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/broadcasts/{broadcastId}:
get:
operationId: getBroadcast
summary: Get broadcast details
description: Returns a broadcast with its full configuration and delivery stats.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast details with stats
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
message: { type: object, properties: { text: { type: string } } }
template: { type: object, properties: { name: { type: string }, language: { type: string } } }
segmentFilters: { type: object, properties: { tags: { type: array, items: { type: string } } } }
status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
scheduledAt: { type: string, format: date-time }
startedAt: { type: string, format: date-time }
completedAt: { type: string, format: date-time }
recipientCount: { type: integer }
sentCount: { type: integer }
deliveredCount: { type: integer }
readCount: { type: integer }
failedCount: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
operationId: updateBroadcast
summary: Update broadcast
description: Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteBroadcast
summary: Delete broadcast
description: Permanently delete a broadcast. Only drafts can be deleted.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Broadcast deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/send:
post:
operationId: sendBroadcast
summary: Send broadcast now
description: Immediately start sending a draft broadcast to its recipients.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast sending started
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
status: { type: string, enum: [sending, completed, failed], description: Current broadcast status after processing first batch }
sent: { type: integer, description: Recipients sent in this batch }
failed: { type: integer, description: Recipients failed in this batch }
recipientCount: { type: integer, description: Total recipient count }
'400': { description: Invalid status or no recipients }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/schedule:
post:
operationId: scheduleBroadcast
summary: Schedule broadcast for later
description: Schedule a draft broadcast to be sent at a future date and time.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scheduledAt]
properties:
scheduledAt: { type: string, format: date-time }
responses:
'200':
description: Broadcast scheduled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
status: { type: string }
scheduledAt: { type: string, format: date-time }
'400': { description: Invalid date or status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/cancel:
post:
operationId: cancelBroadcast
summary: Cancel broadcast
description: Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Broadcast cancelled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcast:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Cannot cancel in current status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/broadcasts/{broadcastId}/recipients:
get:
operationId: listBroadcastRecipients
summary: List broadcast recipients
description: Returns recipients for a broadcast with individual delivery status. Filter by status.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [pending, sent, delivered, read, failed] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Recipients list with delivery status
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
recipients:
type: array
items:
type: object
properties:
id: { type: string }
contactId: { type: string }
channelId: { type: string }
platformIdentifier: { type: string }
contactName: { type: string }
status: { type: string, enum: [pending, sent, delivered, read, failed] }
messageId: { type: string }
error: { type: string }
sentAt: { type: string, format: date-time }
deliveredAt: { type: string, format: date-time }
readAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
post:
operationId: addBroadcastRecipients
summary: Add recipients to a broadcast
description: Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters.
tags: [Broadcasts]
parameters:
- { name: broadcastId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
contactIds: { type: array, items: { type: string }, description: Specific contact IDs to add }
phones: { type: array, items: { type: string }, description: Raw phone numbers (auto-creates contacts). Useful for WhatsApp/Telegram manual entry }
useSegment: { type: boolean, description: Auto-populate from broadcast segment filters }
responses:
'200':
description: Recipients added
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
added: { type: integer, description: Number of recipients successfully added }
skipped: { type: integer, description: Number skipped (duplicates or missing channels) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Sequences ────────────────────────────────────────────────────
/v1/sequences:
get:
operationId: listSequences
summary: List sequences
description: Returns sequences with enrollment stats. Filter by status, platform, or profile.
tags: [Sequences]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, active, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Sequences list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequences:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
messagePreview: { type: string, description: First step template name or message text snippet }
status: { type: string, enum: [draft, active, paused] }
stepsCount: { type: integer }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
totalEnrolled: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createSequence
summary: Create sequence
description: Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template.
tags: [Sequences]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
name: { type: string }
description: { type: string }
steps:
type: array
items:
type: object
required: [order, delayMinutes]
properties:
order: { type: integer }
delayMinutes: { type: integer }
message: { type: object, properties: { text: { type: string } } }
template:
type: object
properties:
name: { type: string }
language: { type: string }
variableMapping:
type: object
description: Maps template variable positions to contact fields. Keys are position strings ("1", "2"), values are objects with field and optional customValue
additionalProperties:
type: object
properties:
field: { type: string, enum: [name, phone, email, company, custom] }
customValue: { type: string, description: Static value when field is "custom" }
exitOnReply: { type: boolean, default: true }
exitOnUnsubscribe: { type: boolean, default: true }
responses:
'200':
description: Sequence created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
status: { type: string }
stepsCount: { type: integer }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sequences/{sequenceId}:
get:
operationId: getSequence
summary: Get sequence with steps
description: Returns a sequence with all its steps and enrollment stats.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence details with steps
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
status: { type: string, enum: [draft, active, paused] }
steps:
type: array
items:
type: object
properties:
order: { type: integer }
delayMinutes: { type: integer }
message: { type: object, properties: { text: { type: string } } }
template: { type: object, properties: { name: { type: string }, language: { type: string }, variableMapping: { type: object } } }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
totalEnrolled: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
operationId: updateSequence
summary: Update sequence
description: Update a sequence's name, steps, or exit conditions. Active sequences can be updated without pausing.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
steps: { type: array, items: { type: object } }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteSequence
summary: Delete sequence
description: Permanently delete a sequence. Active enrollments are stopped.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Sequence deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/activate:
post:
operationId: activateSequence
summary: Activate sequence
description: Start a draft or paused sequence. The sequence must have at least one step.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence activated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Invalid status or no steps }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/pause:
post:
operationId: pauseSequence
summary: Pause sequence
description: Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Sequence paused
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequence:
type: object
properties:
id: { type: string }
status: { type: string }
'400': { description: Sequence is not active }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enroll:
post:
operationId: enrollContacts
summary: Enroll contacts in a sequence
description: Enroll one or more contacts into a sequence. Contacts already enrolled are skipped.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [contactIds]
properties:
contactIds: { type: array, items: { type: string } }
channelIds: { type: array, items: { type: string }, description: Optional. Auto-detected if not provided. }
responses:
'200':
description: Enrollment results
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
enrolled: { type: integer, description: Number of contacts successfully enrolled }
skipped: { type: integer, description: Number skipped (already enrolled or missing channel) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enroll/{contactId}:
delete:
operationId: unenrollContact
summary: Unenroll contact
description: Remove a contact from a sequence. No further messages will be sent to this contact.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
- { name: contactId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Contact unenrolled }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences/{sequenceId}/enrollments:
get:
operationId: listSequenceEnrollments
summary: List enrollments for a sequence
description: Returns enrolled contacts with their progress, status, and next scheduled step.
tags: [Sequences]
parameters:
- { name: sequenceId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [active, completed, exited, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Enrollments list with progress
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
enrollments:
type: array
items:
type: object
properties:
id: { type: string }
contactId: { type: string }
channelId: { type: string }
platformIdentifier: { type: string }
contactName: { type: string }
currentStepIndex: { type: integer }
status: { type: string, enum: [active, completed, exited, paused] }
exitReason: { type: string }
nextStepAt: { type: string, format: date-time }
stepsSent: { type: integer }
lastStepSentAt: { type: string, format: date-time }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ──────────────────────────────────────────────────────────────────────────
# COMMENT AUTOMATIONS (Comment-to-DM)
# ──────────────────────────────────────────────────────────────────────────
/v1/comment-automations:
get:
operationId: listCommentAutomations
tags: [Comment Automations]
summary: List comment-to-DM automations
description: List all comment-to-DM automations for a profile. Returns automations with their stats.
security:
- bearerAuth: []
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
responses:
'200':
description: Automations list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automations:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string, enum: [instagram, facebook] }
accountId: { type: string }
platformPostId: { type: string }
postTitle: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains] }
dmMessage: { type: string }
commentReply: { type: string }
isActive: { type: boolean }
stats:
type: object
properties:
triggered: { type: integer }
dmsSent: { type: integer }
dmsFailed: { type: integer }
uniqueContacts: { type: integer }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createCommentAutomation
tags: [Comment Automations]
summary: Create comment-to-DM automation
description: |
Create a keyword-triggered DM automation on an Instagram or Facebook account.
When someone comments a matching keyword, they automatically receive a DM.
Two modes:
* **Per-post** — set `platformPostId` to scope the automation 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.
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 }
platformPostId: { type: string, description: "Platform media/post ID. Omit for an account-wide (any-post) automation." }
postId: { type: string, description: "Zernio post ID. Required only when also targeting a specific post via platformPostId." }
postTitle: { type: string, description: Post content snippet for display }
name: { type: string, description: Automation label }
keywords:
type: array
items: { type: string }
description: Trigger keywords (empty = any comment triggers)
matchMode: { type: string, enum: [exact, contains], default: contains }
dmMessage: { type: string, description: DM text to send to commenter }
commentReply: { type: string, description: Optional public reply to the comment }
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 }
platformPostId: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains] }
dmMessage: { type: string }
commentReply: { type: string }
isActive: { type: boolean }
stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
createdAt: { type: string, format: date-time }
'400': { description: Validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: "Active per-post automation already exists for this platformPostId. Does not apply to account-wide automations." }
/v1/comment-automations/{automationId}:
get:
operationId: getCommentAutomation
tags: [Comment Automations]
summary: Get automation details
description: Returns an automation with its configuration, stats, and recent trigger logs.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Automation details with stats and recent trigger logs
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string }
accountId: { type: string }
platformPostId: { type: string }
postId: { type: string }
postTitle: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains] }
dmMessage: { type: string }
commentReply: { type: string }
isActive: { type: boolean }
stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
logs:
type: array
items:
type: object
properties:
id: { type: string }
commentId: { type: string }
commenterId: { type: string }
commenterName: { type: string }
commentText: { type: string }
status: { type: string, enum: [sent, failed, skipped], description: DM outcome }
error: { type: string, description: DM error message if status is failed }
commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
operationId: updateCommentAutomation
tags: [Comment Automations]
summary: Update automation settings
description: Update an automation's keywords, DM message, comment reply, or active status.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains] }
dmMessage: { type: string }
commentReply: { type: string }
isActive: { type: boolean }
responses:
'200':
description: Automation updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains] }
dmMessage: { type: string }
commentReply: { type: string }
isActive: { type: boolean }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteCommentAutomation
tags: [Comment Automations]
summary: Delete automation
description: Permanently delete an automation and all its trigger logs.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Automation deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/comment-automations/{automationId}/logs:
get:
operationId: listCommentAutomationLogs
tags: [Comment Automations]
summary: List automation logs
description: Paginated list of every comment that triggered this automation, with send status and commenter info.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [sent, failed, skipped] }, description: Filter by result status }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Trigger logs with pagination
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
logs:
type: array
items:
type: object
properties:
id: { type: string }
commentId: { type: string }
commenterId: { type: string }
commenterName: { type: string }
commentText: { type: string }
status: { type: string, enum: [sent, failed, skipped], description: DM outcome }
error: { type: string, description: DM error message if status is failed }
commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ── Ads ──────────────────────────────────────────────────────────────────
/v1/ads:
get:
operationId: listAds
tags: [Ads]
summary: List ads
description: |
Returns a paginated list of ads with metrics computed over an optional date range.
Use source=all to include externally-synced ads from platform ad managers.
If no date range is provided, defaults to the last 90 days. Date range is capped at 90 days max.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only." }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' } }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
- { name: accountId, in: query, schema: { type: string }, description: Social account ID }
- { name: adAccountId, in: query, schema: { type: string }, description: "Platform ad account ID (e.g. act_123 for Meta). Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree." }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: campaignId, in: query, schema: { type: string }, description: Platform campaign ID (filter ads within a campaign) }
- { name: 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 90-day range." }
responses:
'200':
description: Paginated ads
content:
application/json:
schema:
type: object
properties:
ads:
type: array
items: { $ref: '#/components/schemas/Ad' }
pagination: { $ref: '#/components/schemas/Pagination' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
/v1/ads/campaigns:
get:
operationId: listAdCampaigns
tags: [Ad Campaigns]
summary: List campaigns
description: |
Returns campaigns as virtual aggregations over ad documents grouped by platform campaign ID.
Metrics (spend, impressions, clicks, etc.) are summed across all ads in each campaign.
Campaign status is derived from child ad statuses (active > pending_review > paused > error > completed > cancelled > rejected).
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that." }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
- { name: adAccountId, in: query, schema: { type: string }, description: Platform ad account ID (e.g. act_123 for Meta) }
- { name: accountId, in: query, schema: { type: string }, description: Social account ID }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
responses:
'200':
description: Paginated campaigns
content:
application/json:
schema:
type: object
properties:
campaigns:
type: array
items: { $ref: '#/components/schemas/AdCampaign' }
pagination: { $ref: '#/components/schemas/Pagination' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
/v1/ads/campaigns/{campaignId}/status:
put:
operationId: updateAdCampaignStatus
tags: [Ad Campaigns]
summary: Pause or resume a campaign
description: |
Updates the status of all ads in a campaign. Makes one platform API call (not per-ad) since status cascades through the campaign hierarchy.
Ads in terminal statuses (rejected, completed, cancelled) are automatically skipped.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, platform]
properties:
status: { type: string, enum: [active, paused] }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
responses:
'200':
description: Campaign status updated
content:
application/json:
schema:
type: object
properties:
updated: { type: integer, description: Number of ads updated }
skipped: { type: integer, description: Number of ads skipped }
skippedReasons: { type: array, items: { type: string } }
message: { type: string, description: Human-readable summary (present when no ads were actionable) }
'400':
description: Invalid input or campaign spans multiple social accounts
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: No ads found for this campaign
/v1/ads/campaigns/{campaignId}:
put:
operationId: updateAdCampaign
tags: [Ad Campaigns]
summary: Update a campaign (budget and/or bid strategy)
description: |
Campaign-level edits. At least one of `budget` or `bidStrategy` is required.
- `budget` updates the CBO (Campaign Budget Optimization) budget. For ABO campaigns
(where the budget lives on the ad set), use PUT /v1/ads/ad-sets/{adSetId} instead — this endpoint
will return 409 with code BUDGET_LEVEL_MISMATCH.
- `bidStrategy` sets the campaign-level default bid strategy. Per Meta's spec, `bid_amount` and
`bid_constraints` do NOT exist at the campaign level — pass them via PUT /v1/ads/ad-sets/{adSetId}.
Meta-only for now. Other platforms return 501 Not Implemented.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram] }
budget:
type: object
required: [amount, type]
properties:
amount: { type: number, description: Budget amount in the ad account's currency }
type: { type: string, enum: [daily, lifetime] }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: "Campaign-level default. Ad sets inherit this unless they override."
responses:
'200':
description: Campaign updated
content:
application/json:
schema:
type: object
properties:
updated: { type: integer }
budget: { $ref: '#/components/schemas/AdBudget' }
budgetLevel: { type: string, enum: [campaign] }
bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Campaign not found }
'409': { description: "Campaign is ABO — route to /v1/ads/ad-sets/{adSetId} instead" }
'501': { description: Operation not supported on this platform }
delete:
operationId: deleteAdCampaign
tags: [Ad Campaigns]
summary: Delete a campaign
description: |
Deletes the whole campaign on the platform, cascading to its ad sets
and ads. Locally, all Ad documents for this campaign are marked
`status: cancelled`.
Meta-only for now. Other platforms return 501 Not Implemented — fall
back to DELETE /v1/ads/{adId} per ad in the meantime.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram] }
responses:
'200':
description: Campaign deleted
content:
application/json:
schema:
type: object
properties:
deleted: { type: boolean }
adCount: { type: integer, description: Number of local Ad docs marked cancelled }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Campaign not found }
'501': { description: Operation not supported on this platform }
/v1/ads/campaigns/bulk-status:
post:
operationId: bulkUpdateAdCampaignStatus
tags: [Ad Campaigns]
summary: Pause or resume many campaigns
description: |
Process up to 50 campaigns in one call. Each campaign is updated
concurrently and the response contains a per-campaign result so a
single bad row does not fail the whole batch.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, campaigns]
properties:
status: { type: string, enum: [active, paused] }
campaigns:
type: array
maxItems: 50
items:
type: object
required: [platformCampaignId, platform]
properties:
platformCampaignId: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
responses:
'200':
description: Per-campaign results
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [active, paused] }
totals:
type: object
properties:
updated: { type: integer }
skipped: { type: integer }
failed: { type: integer }
results:
type: array
items:
type: object
properties:
platformCampaignId: { type: string }
platform: { type: string }
updated: { type: integer }
skipped: { type: integer }
error: { type: string }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/campaigns/{campaignId}/duplicate:
post:
operationId: duplicateAdCampaign
tags: [Ad Campaigns]
summary: Duplicate a campaign
description: |
Duplicates a campaign, including its ad sets, ads, creatives, and
targeting by default (`deepCopy: true`). The copy is created paused
so callers can review before launching.
Per-platform implementation:
- **Meta** uses the native `POST /{campaign-id}/copies` endpoint.
- **TikTok** has no native copy primitive; Zernio walks the source
graph (`/v2/campaign/get/`, `/v2/adgroup/get/`, `/v2/ad/get/`) and
recreates each entity via the corresponding `/create/` endpoints,
carrying over budget / targeting / bid_type / bid_price /
deep_bid_type / creative fields. Spark Ad linkage (`tiktok_item_id`)
is preserved.
The new hierarchy is asynchronous to materialize in our DB — we
trigger sync discovery automatically. Set `syncAfter: false` to
skip and poll `/v1/ads/tree` on your own cadence.
Other platforms return 501 Not Implemented.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Source platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, tiktok] }
deepCopy: { type: boolean, default: true, description: Copy child ad sets + ads + creatives + targeting }
statusOption:
type: string
enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE]
default: PAUSED
startTime: { type: string, format: date-time, description: Reschedule the copied hierarchy's start time }
endTime: { type: string, format: date-time }
renameStrategy:
type: string
enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME]
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true, description: Trigger ads discovery on the owning account after the copy succeeds }
responses:
'200':
description: Campaign duplicated
content:
application/json:
schema:
type: object
properties:
copiedCampaignId: { type: string, description: Platform ID of the new campaign }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw:
type: object
description: Platform-native response from the copy endpoint (Meta includes ad_object_ids for child copies)
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Source campaign not found }
'501': { description: Operation not supported on this platform }
/v1/ads/ad-sets/{adSetId}:
put:
operationId: updateAdSet
tags: [Ad Campaigns]
summary: Update an ad set (budget, status, and/or bid strategy)
description: |
Ad-set-level writes. Use this for ABO budget updates, ad-set-scoped
pause/resume, and bid-strategy edits. At least one of `budget`,
`status`, or `bidStrategy` is required.
Bid strategy compatibility (per Meta's spec):
- `LOWEST_COST_WITHOUT_CAP`: no `bidAmount`, no `roasAverageFloor`.
- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: `bidAmount` REQUIRED (whole currency units).
- `LOWEST_COST_WITH_MIN_ROAS`: `roasAverageFloor` REQUIRED (decimal multiplier, e.g. 2.0 = 2.0x ROAS).
When updating `budget` on an ABO campaign: if the parent campaign is
CBO, the response is 409 with code BUDGET_LEVEL_MISMATCH — route to
PUT /v1/ads/campaigns/{campaignId} instead.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
budget:
type: object
description: Omit if not updating budget
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
status: { type: string, enum: [active, paused], description: Omit if not toggling delivery state }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: |
Ad-set-level bid strategy. Overrides the campaign-level default.
Supported on Meta (facebook, instagram) and TikTok. On TikTok the
Meta-style enum is mapped to bid_type / bid_price / deep_bid_type
automatically. Other platforms (linkedin, pinterest, google, twitter)
return 501 Not Implemented when bidStrategy is set.
bidAmount:
type: number
description: |
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. Internally converted to Meta's
smallest-denomination integer.
roasAverageFloor:
type: number
description: |
Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Required when bidStrategy is
LOWEST_COST_WITH_MIN_ROAS. Sent to Meta as `bid_constraints.roas_average_floor` × 10000.
responses:
'200':
description: Ad set updated
content:
application/json:
schema:
type: object
properties:
budget: { $ref: '#/components/schemas/AdBudget' }
budgetLevel: { type: string, enum: [adset] }
status: { type: string, enum: [active, paused] }
statusUpdated: { type: integer }
statusSkipped: { type: integer }
bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
bidAmount: { type: number, nullable: true }
roasAverageFloor: { type: number, nullable: true }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Ad set not found }
'409': { description: "Campaign is CBO — route to /v1/ads/campaigns/{campaignId} instead" }
'501': { description: "bidStrategy not supported on the platform (Meta + TikTok only)" }
/v1/ads/ad-sets/{adSetId}/status:
put:
operationId: updateAdSetStatus
tags: [Ad Campaigns]
summary: Pause or resume a single ad set
description: |
Ad-set-scoped pause/resume (doesn't touch sibling ad sets). Thin wrapper
over PUT /v1/ads/ad-sets/{adSetId} for callers that only want the
status toggle and prefer a symmetric URL to
/v1/ads/campaigns/{campaignId}/status.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, platform]
properties:
status: { type: string, enum: [active, paused] }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
responses:
'200':
description: Ad set status updated
content:
application/json:
schema:
type: object
properties:
updated: { type: integer }
skipped: { type: integer }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Ad set not found }
/v1/ads/tree:
get:
operationId: getAdTree
tags: [Ad Campaigns]
summary: Get campaign tree
description: |
Returns a nested Campaign > Ad Set > Ad hierarchy with rolled-up metrics at each level.
Uses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns.
Metrics are computed over an optional date range, then rolled up from ad level to ad set
and campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set
ID are grouped into synthetic "Ungrouped" buckets.
If no date range is provided, defaults to the last 90 days. Date range is capped at 90 days max.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 }, description: Campaigns per page }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that." }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
- { name: adAccountId, in: query, schema: { type: string }, description: Platform ad account ID }
- { name: accountId, in: query, schema: { type: string }, description: Social account ID }
- { name: profileId, in: query, schema: { type: string }, description: Profile ID }
- { name: 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 90-day range." }
responses:
'200':
description: Nested campaign tree with pagination
content:
application/json:
schema:
type: object
properties:
campaigns:
type: array
items: { $ref: '#/components/schemas/AdTreeCampaign' }
pagination: { $ref: '#/components/schemas/Pagination' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
/v1/ads/{adId}:
get:
operationId: getAd
tags: [Ads]
summary: Get ad details
description: Returns an ad with its creative, targeting, status, and performance metrics.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Ad details
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
put:
operationId: updateAd
tags: [Ads]
summary: Update ad
description: |
Patch one or more fields on an ad. Status, budget, targeting, and creative changes
are propagated to the platform.
Per-platform support:
- **Meta** (Facebook + Instagram): all fields supported.
- **TikTok**: status, budget, targeting (via `/v2/adgroup/update/`), and creative
(via `/v2/ad/update/` patch-style — `headline` is ignored, `body` becomes `ad_text`).
- **Pinterest / X / LinkedIn / Google**: status + budget only. Sending `targeting`
or `creative` returns 501 with code `unsupported_platform_operation`.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [active, paused] }
budget:
type: object
properties:
amount: { type: number, description: "Minimum varies by platform: TikTok=$20, Pinterest=$5, others=$1" }
type: { type: string, enum: [daily, lifetime] }
targeting:
type: object
description: |
Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501.
properties:
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
countries: { type: array, items: { type: string } }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. Omit to preserve the existing setting on update. 0 = disabled, 1 = enabled." }
creative:
type: object
description: |
Replace the ad's creative. Meta + TikTok only.
- **Meta**: requires `headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`. The
ad's existing creative is replaced via a new `/act_X/adcreatives` upload + ad
update. The old creative is retained on the ad account for historical reporting.
- **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives
have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes
`landing_page_url`; `videoUrl` triggers a fresh upload.
properties:
headline: { type: string, description: "Meta only" }
body: { type: string }
callToAction: { type: string }
linkUrl: { type: string, format: uri }
imageUrl: { type: string, format: uri }
videoUrl: { type: string, format: uri }
name: { type: string }
responses:
'200':
description: Ad updated
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
'400':
description: Invalid status transition or budget below minimum
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'501': { description: "targeting or creative not supported on the platform (Meta + TikTok only)" }
delete:
operationId: deleteAd
tags: [Ads]
summary: Cancel an ad
description: Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Ad cancelled
content:
application/json:
schema:
type: object
properties:
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/{adId}/analytics:
get:
operationId: getAdAnalytics
tags: [Ads]
summary: Get ad analytics
description: |
Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline
over the requested date range, and optional demographic breakdowns (Meta and TikTok only).
If no date range is provided, defaults to the last 90 days. Date range is capped at 90 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 90-day range." }
- name: breakdowns
in: query
schema: { type: string }
description: "Comma-separated breakdown dimensions. Meta: age, gender, country, publisher_platform, device_platform, region. TikTok: gender, age, country_code, platform, ac, language."
responses:
'200':
description: Ad analytics
content:
application/json:
schema:
type: object
properties:
ad:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string }
status: { type: string }
analytics:
type: object
properties:
summary: { $ref: '#/components/schemas/AdMetrics' }
daily:
type: array
items:
allOf:
- { $ref: '#/components/schemas/AdMetrics' }
- type: object
properties:
date: { type: string, format: date }
breakdowns:
type: object
additionalProperties:
type: array
items: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/{adId}/comments:
get:
operationId: getAdComments
tags: [Ads]
summary: List comments on an ad
description: |
Returns comments on an ad's underlying creative post. Useful for moderating or analyzing
engagement on dark posts (ad creatives that never went live organically), which the
regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are
not in Zernio's post database.
Resolves the ad's creative effective_object_story_id (Facebook) or
effective_instagram_media_id (Instagram) via the Marketing API on each call
(cached in-process by the platform client), then fetches comments from the Graph API.
Meta-only. Other ad platforms (TikTok, LinkedIn, Pinterest, Google, X) do not
expose a public per-ad comments API and return feature_not_available.
Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Internal Zernio ad ID (ObjectId)." }
- { 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, adId, platformAdId, effectiveStoryId, accountId, lastUpdated]
properties:
platform: { type: string, enum: [facebook, instagram] }
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 Facebook, effective_instagram_media_id for Instagram."
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 add-on required, or ad platform is not Meta (code feature_not_available).
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: Ads connection missing or account token unavailable (code ads_connection_required).
/v1/ads/business-centers:
get:
operationId: listAdsBusinessCenters
tags: [Ads]
summary: List TikTok Business Centers
description: |
Returns the TikTok Business Centers (BCs) the connected `tiktokads` account can read.
Each BC reports its advertiser count so callers can build agency-style pickers
without re-walking `/v1/ads/accounts` per BC.
TikTok-only. Solo advertisers (non-agency tokens) return an empty array.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount }
responses:
'200':
description: Business centers
content:
application/json:
schema:
type: object
properties:
businessCenters:
type: array
items: { $ref: '#/components/schemas/BusinessCenter' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: TikTok account not found }
'422': { description: TikTok Ads not connected }
/v1/ads/accounts:
get:
operationId: listAdAccounts
tags: [Ads]
summary: List ad accounts
description: |
Returns the platform ad accounts available for the given social account (e.g. Meta ad
accounts, TikTok advertiser IDs, Google Ads customer IDs).
For TikTok agencies: enumerates every advertiser under every Business Center the token
can read (paginated server-side), then chunks the lookup against TikTok's
`/advertiser/info/` endpoint (which has a per-call cap of ≤100 IDs). Solo advertisers
without a BC fall back to the OAuth-time `advertiser_ids` list. Cached for 1h on the
SocialAccount; lazy-refreshed on first call after expiry.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
- { name: adAccountId, in: query, required: false, schema: { type: string }, description: "Filter response to a single platform ad account ID (e.g. `act_123` for Meta, advertiser_id for TikTok). Returns at most one item." }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000 }, description: "Clamp the returned `accounts[]` length. Useful for typeahead pickers on agency tokens with hundreds of advertisers." }
responses:
'200':
description: Ad accounts
content:
application/json:
schema:
type: object
properties:
accounts:
type: array
items:
type: object
properties:
id: { type: string, description: "Platform ad account ID (e.g. act_123)" }
name: { type: string }
currency: { type: string }
status: { type: string }
timezoneName: { type: string, description: "IANA timezone of the ad account (Meta only). Drives daily-budget reset and Insights day boundaries." }
timezoneOffsetHoursUtc: { type: number, description: "Signed UTC offset in hours, reflecting current DST (Meta only)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'422':
description: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account
/v1/ads/boost:
post:
operationId: boostPost
tags: [Ads]
summary: Boost post as ad
description: Creates a paid ad campaign from an existing published post. Creates the full platform campaign hierarchy (campaign, ad set, ad).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, goal, budget]
properties:
postId: { type: string, description: Zernio post ID (provide this or platformPostId) }
platformPostId: { type: string, description: Platform post ID (alternative to postId) }
accountId: { type: string, description: Social account ID }
adAccountId: { type: string, description: Platform ad account ID }
name: { type: string, maxLength: 255 }
goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views." }
budget:
type: object
required: [amount, type]
properties:
amount: { type: number, description: "Minimum varies: TikTok=$20, Pinterest=$5, others=$1" }
type: { type: string, enum: [daily, lifetime] }
currency: { type: string, example: USD }
schedule:
type: object
properties:
startDate: { type: string, format: date-time }
endDate: { type: string, format: date-time, description: Required for lifetime budgets }
targeting:
type: object
properties:
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
countries: { type: array, items: { type: string }, description: "ISO country codes. Required for TikTok boosts (TikTok's ad group requires location_ids); optional on other platforms." }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. 0 = disabled (default), 1 = enabled." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: |
Meta bid strategy applied to the ad set. On TikTok, mapped to
`bid_type` / `bid_price` / `deep_bid_type` automatically.
bidAmount:
type: number
description: |
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing
`bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`.
roasAverageFloor:
type: number
description: |
Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
`bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers).
tracking:
type: object
description: "Meta only. Tracking specs (pixel, URL tags)."
properties:
pixelId: { type: string }
urlTags: { type: string }
specialAdCategories:
type: array
description: "Meta only. Required for housing, employment, credit, or political ads."
items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS] }
linkUrl:
type: string
format: uri
description: |
TikTok-only. Custom destination URL for the Spark Ad. Without this, TikTok
Spark Ads have no clickable destination — required for traffic / conversion
objectives. Maps to `landing_page_url` on the creative entry of /v2/ad/create/
(TikTok SDK `AdcreateCreatives.landing_page_url`). Ignored on Meta / LinkedIn /
Pinterest / X / Google (those infer the destination from the boosted post).
callToAction:
type: string
description: |
TikTok-only. Call-to-action button label on the Spark Ad creative (e.g.
`LEARN_MORE`, `SHOP_NOW`, `DOWNLOAD_NOW`, `SIGN_UP`, `WATCH_NOW`). Maps to
`call_to_action` on the creative entry of /v2/ad/create/. Pass-through —
the platform validates the value. See TikTok's "Enumeration - Call-to-Action"
reference for the full list.
sparkAuthCode:
type: string
description: |
TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator
Spark Ads — the advertiser can boost a video owned by a DIFFERENT TikTok
account. Without this, boosts are limited to videos owned by the same
account running the ads (same-BC creators only). The creator generates the
code in their TikTok app's Promote settings and shares it with the
advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.
dsaBeneficiary:
type: string
maxLength: 100
description: |
Name of the legal entity benefiting from the ad.
Required by Meta when targeting EU users (DSA Article 26).
Not enforced at schema level; enforced server-side when targeting intersects EU member states.
dsaPayor:
type: string
maxLength: 100
description: |
Name of the legal entity paying for the ad.
Required by Meta when targeting EU users (DSA Article 26).
Note Meta API spelling: dsa_payor (not dsa_payer).
responses:
'201':
description: Ad created
content:
application/json:
schema:
type: object
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
'400':
description: Missing required fields or invalid values
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'422':
description: |
Platform ads connection required (TikTok Ads, X Ads), missing linked
account, or — for TikTok — the connected TikTok user is not authorized
as an Identity on the target advertiser. Returned with code
`ads_connection_required`; the message includes the actionable
"TikTok Ads Manager → Assets → Identity" remediation step.
/v1/ads/create:
post:
operationId: createStandaloneAd
tags: [Ads]
summary: Create standalone ad
description: >-
Creates a paid ad with custom creative across Meta, Google Ads,
Pinterest, TikTok, and X/Twitter. Supports three mutually-exclusive
request shapes selected by the body, a legacy single-creative shape
(all platforms, default), a Meta-only multi-creative shape via the
creatives array (one ad set with N ads sharing budget and targeting),
and a Meta-only attach shape via adSetId (adds one new ad to an
existing ad set). Per-platform required fields, budget minimums, and
video-ad rules (Meta only) are documented on each property below.
security:
- bearerAuth: []
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 }
goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Required on legacy + multi-creative shapes. Inherited from the ad set on the attach shape. Available goals vary by platform. Meta-specific: `conversions` requires `promotedObject.pixelId` + `promotedObject.customEventType`; `app_promotion` requires `promotedObject.applicationId` + `promotedObject.objectStoreUrl`; `lead_generation` accepts an optional `promotedObject.pageId` (auto-filled from the connected Page when omitted)." }
budgetAmount: { type: number, description: "Required on legacy + multi-creative shapes. Inherited on attach." }
budgetType: { type: string, enum: [daily, lifetime], description: "Required on legacy + multi-creative shapes. Inherited on attach." }
currency: { type: string }
headline: { type: string, description: "Required for Meta, Google, and Pinterest on legacy + attach shapes (skip for multi-creative — use `creatives[].headline`). Ignored for TikTok and X/Twitter. Max: Meta=255, Google=30, Pinterest=100." }
longHeadline: { type: string, maxLength: 90, description: "Google Display only. Defaults to `headline` if omitted." }
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). Max: Google=90, Pinterest=500." }
callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE], description: "Required on legacy + attach shapes for Meta. Honoured on TikTok too — passes through to the Spark Ad creative's `call_to_action`. Ignored by other platforms." }
linkUrl: { type: string, format: uri, description: "Required on legacy + attach shapes. Skip for multi-creative." }
imageUrl: { type: string, format: uri, description: "Image creative for Meta/Google/Pinterest on legacy + attach shapes (mutually exclusive with `video`). 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." }
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 only (facebook, instagram). When set, creates a VIDEO ad on the legacy or attach shape. Mutually exclusive with `imageUrl`. For multi-creative, set `video` per entry inside `creatives[]` instead."
required: [url, thumbnailUrl]
properties:
url: { type: string, format: uri, description: "Public URL of the video. Uploaded to Meta via chunked transfer on /act_X/advideos; then the request blocks on Meta's transcoding until status.video_status === 'ready'." }
thumbnailUrl: { type: string, format: uri, description: "Public URL of a still-image thumbnail for the video. Required by Meta on every video creative. Uploaded to Meta as an ad image and referenced as the thumbnail in object_story_spec.video_data." }
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:
headline: { type: string, maxLength: 255 }
body: { type: string }
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`."
required: [url, thumbnailUrl]
properties:
url: { type: string, format: uri }
thumbnailUrl: { type: string, format: uri }
linkUrl: { type: string, format: uri }
callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE] }
adSetId:
type: string
description: |
Meta-only. When present, switches to the attach shape: adds
one new ad to this existing ad set without creating a new
campaign. Budget, targeting, goal, schedule, AND bid strategy
are inherited from the ad set on Meta — passing `bidStrategy`
in attach mode returns 400. To change an existing ad set's
bid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive
with `creatives[]`.
Supported on Meta (facebook, instagram) and TikTok. On TikTok
the `adSetId` is the ad group ID; the new ad inherits the
ad group's bid + budget + targeting.
businessName: { type: string, maxLength: 25, description: "Google Display only" }
boardId: { type: string, description: "Pinterest only. Board ID (auto-creates if not provided)." }
countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no `cities` or `regions` are provided." }
cities:
type: array
description: |
Meta-only. City-level geo targeting. Each city is targeted by Meta's opaque `key` (the city ID) which can be looked up via `GET /v1/ads/targeting/search?type=city&q=<name>&country_code=<ISO>`. Optional `radius` + `distance_unit` extend the targeting beyond the city limits (e.g. radius 25 km around the city center). Both must be set together, or both omitted (Meta defaults to ~16 km when omitted).
Cannot overlap with the same country in `countries` (Meta returns a "locations overlap" error). Either drop the country or scope it to a different country.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Meta city ID, from /v1/ads/targeting/search results." }
radius: { type: number, description: "Optional radius around the city. Must be set together with distance_unit." }
distance_unit: { type: string, enum: [mile, kilometer], description: "Unit for radius. Required if radius is set." }
regions:
type: array
description: |
Meta-only. Region-level (state/province) geo targeting. Each region is targeted by Meta's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?type=region&q=<name>&country_code=<ISO>`.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Meta region ID, from /v1/ads/targeting/search results." }
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
endDate: { type: string, format: date-time, description: Required for lifetime budgets }
audienceId: { type: string, description: Custom audience ID for targeting }
campaignType: { type: string, enum: [display, search], default: display, description: Google only }
keywords: { type: array, items: { type: string }, description: Google Search only }
additionalHeadlines: { type: array, items: { type: string }, description: "Google Search RSA only. Extra headlines." }
additionalDescriptions: { type: array, items: { type: string }, description: "Google Search RSA only. Extra descriptions." }
advantageAudience: { type: integer, enum: [0, 1], description: "Meta only. Controls the Advantage audience feature (targeting_automation). 0 = disabled (default), 1 = enabled. Meta Marketing API requires this field on all ad set creation requests." }
gender: { type: string, enum: [all, male, female], default: all, description: "Meta only. Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Ignored by non-Meta platforms." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: "Meta bid strategy applied to the ad set."
bidAmount:
type: number
description: |
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`.
roasAverageFloor:
type: number
description: |
Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
`bid_constraints.roas_average_floor` × 10000.
dsaBeneficiary:
type: string
maxLength: 100
description: |
Name of the legal entity benefiting from the ad.
Required by Meta when targeting EU users (DSA Article 26).
Not enforced at schema level; enforced server-side when targeting intersects EU member states.
dsaPayor:
type: string
maxLength: 100
description: |
Name of the legal entity paying for the ad.
Required by Meta when targeting EU users (DSA Article 26).
Note Meta API spelling: dsa_payor (not dsa_payer).
brandIdentity:
type: object
description: |
TikTok only. Synthetic Brand Identity used when the ad
attributes to a CUSTOMIZED_USER (instead of a real TT_USER
@username). Required on the FIRST CUSTOMIZED_USER ad on a
`tiktokads` SocialAccount with no cached identity; omit on
subsequent ads (the identity is cached on the account after
first creation). Non-TikTok platforms ignore this field.
Alternative: configure once via `PATCH /v1/connect/tiktok-ads`,
then create ads without this field.
required: [displayName, imageUrl]
properties:
displayName:
type: string
minLength: 1
maxLength: 40
description: Brand name shown above the ad on TikTok.
imageUrl:
type: string
format: uri
description: Public URL of a square brand image (≥98×98 px, JPG/PNG). Used as the brand avatar on the ad.
identityType:
type: string
enum: [TT_USER, CUSTOMIZED_USER]
description: |
TikTok only. Forces the identity attribution on the ad:
- `TT_USER`: the posting account's open_id (real @username
branding). Requires a connected TikTok posting account
on the same profile.
- `CUSTOMIZED_USER`: synthetic Brand Identity (display
name + avatar). Requires a configured Brand Identity
(cached on the `tiktokads` SocialAccount via
`PATCH /v1/connect/tiktok-ads`) or an inline
`brandIdentity` to create one on the fly.
When omitted, defaults to `TT_USER` if a posting account is
connected on this profile, else `CUSTOMIZED_USER`. Spark
Ads (`POST /v1/ads/boost`) always use `TT_USER` regardless
of this field — TikTok requires the original organic
post's author identity for Spark.
promotedObject:
type: object
description: |
Meta only. Forwarded to the ad set's `promoted_object` (snake-cased).
Required for goals whose ad-set optimization_goal points at a specific
event/page/app — without it Meta rejects the ad-set create with
`error_subcode: 1815430` "Please select a promoted object for your ad set":
- `goal: conversions` (OFFSITE_CONVERSIONS) — requires `pixelId` + `customEventType`
- `goal: app_promotion` (APP_INSTALLS) — requires `applicationId` + `objectStoreUrl`
- `goal: lead_generation` (LEAD_GENERATION) — `pageId` is auto-filled from the connected Page when omitted
Other goals (engagement, traffic, awareness, video_views) ignore this field.
properties:
pixelId:
type: string
description: "Facebook Pixel ID. Required for `goal: conversions`."
customEventType:
type: string
description: |
Standard event the campaign optimises against, e.g. `PURCHASE`, `LEAD`,
`COMPLETE_REGISTRATION`, `ADD_TO_CART`. Uppercased internally so callers
can pass any case. Required for `goal: conversions`.
pageId:
type: string
description: |
Facebook Page ID. Used by `goal: lead_generation`. Auto-filled from the
connected Page when omitted.
applicationId:
type: string
description: "App ID. Required for `goal: app_promotion`."
objectStoreUrl:
type: string
format: uri
description: "App Store / Play Store listing URL. Required for `goal: app_promotion`."
customConversionId:
type: string
description: Custom Conversion ID, when optimising against one instead of a standard event.
productCatalogId:
type: string
description: Catalog ID for catalog/Advantage+ Shopping campaigns.
productSetId:
type: string
description: Product Set ID inside the catalog.
responses:
'201':
description: Ad(s) created
content:
application/json:
schema:
oneOf:
- type: object
description: "Legacy + attach shapes — one ad returned."
properties:
ad: { $ref: '#/components/schemas/Ad' }
message: { type: string }
- type: object
description: "Multi-creative shape — N ads returned sharing platformCampaignId / platformAdSetId."
properties:
ads:
type: array
items: { $ref: '#/components/schemas/Ad' }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
message: { type: string }
'400':
description: Missing required fields, invalid values, or non-Meta platform used with creatives[] / adSetId
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'422':
description: Platform ads connection required (TikTok Ads, X Ads) or missing linked account
/v1/ads/interests:
get:
operationId: searchAdInterests
tags: [Ads]
summary: Search targeting interests
description: Search for interest-based targeting options available on the platform.
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 add-on required
/v1/ads/targeting/search:
get:
operationId: searchAdTargetingLocations
tags: [Ads]
summary: Search geo targeting locations (Meta)
description: |
Resolve a human-readable location name into Meta's opaque `key` used in
`targeting.cities[]` / `targeting.regions[]` on `POST /v1/ads/create`
(and the same fields under `targeting.geo_locations` on
`POST /v1/ads/boost`). Wraps Meta's `/search?type=adgeolocation`
endpoint.
Meta-only for now. Other platforms have their own location id systems
and are not exposed here.
Per Meta's docs, `q` must contain only the locality name (e.g.
`"Amsterdam"`, not `"Amsterdam, NL"`). Use `countryCode` to
disambiguate when the same name exists in multiple countries.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (must be a connected Facebook or Instagram account)." }
- { name: q, in: query, required: true, schema: { type: string }, description: "Location name. Locality only — no region/country suffix." }
- { name: type, in: query, required: false, schema: { type: string, enum: [country, region, city, subcity, neighborhood, zip, metro_area, geo_market], default: city }, description: "Type of location to search. 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 the 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 locations
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
required: [key, name, type]
properties:
key: { type: string, description: "Meta's opaque location ID. Use this in targeting.cities[].key / regions[].key." }
name: { type: string }
type: { type: string, description: "Location type as returned by Meta (city, region, country, etc.)." }
countryCode: { type: string }
countryName: { type: string }
region: { type: string, description: "Parent region/state name (cities only)." }
regionId: { oneOf: [{ type: string }, { type: integer }], description: "Parent region ID (cities only)." }
supportsRegion: { type: boolean }
supportsCity: { type: boolean }
'400':
description: Missing or invalid query parameters
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'404':
description: Account not found, or platform does not support targeting search (Meta only)
/v1/ads/audiences:
get:
operationId: listAdAudiences
tags: [Ad Audiences]
summary: List custom audiences
description: Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, and Pinterest.
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, pinterest] } }
responses:
'200':
description: Audiences
content:
application/json:
schema:
type: object
properties:
audiences:
type: array
items:
type: object
properties:
id: { type: string, nullable: true }
platformAudienceId: { type: string }
name: { type: string }
description: { type: string }
type: { type: string, enum: [customer_list, website, lookalike] }
platform: { type: string }
size: { type: integer }
status: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
post:
operationId: createAdAudience
tags: [Ad Audiences]
summary: Create custom audience
description: Create a customer list, website retargeting, or lookalike audience on Meta (Facebook/Instagram).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, type]
properties:
accountId: { type: string }
adAccountId: { type: string, description: "Must start with act_" }
name: { type: string, maxLength: 255 }
description: { type: string }
type: { type: string, enum: [customer_list, website, lookalike] }
pixelId: { type: string, description: Required for website audiences }
retentionDays: { type: integer, minimum: 1, maximum: 180, description: Required for website audiences }
sourceAudienceId: { type: string, description: Required for lookalike audiences }
country: { type: string, description: "2-letter code, required for lookalike audiences" }
ratio: { type: number, minimum: 0.01, maximum: 0.20, description: Required for lookalike audiences }
rule: { type: object, description: "Pixel event rule for website audiences (optional)" }
customerFileSource: { type: string, description: "Data source declaration for GDPR compliance (customer_list only)" }
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 add-on required
/v1/ads/audiences/{audienceId}:
get:
operationId: getAdAudience
tags: [Ad Audiences]
summary: Get audience details
description: Returns the local audience record and fresh data from Meta (if available).
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Audience details
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
metaData: { type: object, nullable: true, description: Fresh data from Meta API }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'404': { $ref: '#/components/responses/NotFound' }
delete:
operationId: deleteAdAudience
tags: [Ad Audiences]
summary: Delete custom audience
description: Deletes the audience from both Meta and the local database.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Audience deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/audiences/{audienceId}/users:
post:
operationId: addUsersToAdAudience
tags: [Ad Audiences]
summary: Add users to audience
description: Upload user data (emails and/or phone numbers) to a customer_list audience. Data is SHA256-hashed server-side before sending to Meta. Max 10,000 users per request.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [users]
properties:
users:
type: array
maxItems: 10000
items:
type: object
properties:
email: { type: string, format: email }
phone: { type: string }
description: Each user must have at least email or phone
responses:
'200':
description: Users added
content:
application/json:
schema:
type: object
properties:
message: { type: string }
numReceived: { type: integer }
numInvalid: { type: integer }
'400':
description: Invalid input (empty users array, missing email/phone)
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: Audience is not a customer_list type or has no platform ID yet
/v1/ads/conversions:
post:
operationId: sendConversions
tags: [Ads]
summary: Send conversion events to an ad platform
description: |
Relay one or more conversion events to the target ad platform's native
Conversions API. Supported platforms: Meta (metaads) via Graph API,
Google Ads (googleads) via Data Manager API `ingestEvents`.
Platform is inferred from the provided `accountId`. `destinationId`
semantics differ per platform:
- Meta: pixel (dataset) ID, e.g. "123456789012345"
- Google: conversion action resource name, e.g.
"customers/1234567890/conversionActions/987654321"
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.
Requires the Ads add-on.
Batching: Meta caps at 1000 events per request and rejects the entire
batch if any event is malformed. Google caps at 2000. Both are handled
automatically by chunking.
Dedup: pass a stable `eventId` on every event. Meta uses it to dedupe
against pixel events; Google maps it to transactionId.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, destinationId, events]
properties:
accountId:
type: string
description: SocialAccount ID (metaads or googleads).
destinationId:
type: string
description: |
Platform destination identifier. For Meta, the pixel/dataset
ID. For Google, the conversion action resource name.
events:
type: array
minItems: 1
items: { $ref: '#/components/schemas/ConversionEvent' }
testCode:
type: string
description: Meta `test_event_code` passthrough. Ignored by Google.
consent:
type: object
description: |
Batch-level user consent. Required by Google for EEA/UK
events under the Feb 2026 restrictions. Ignored by Meta.
properties:
adUserData: { type: string, enum: [GRANTED, DENIED] }
adPersonalization: { type: string, enum: [GRANTED, DENIED] }
responses:
'200':
description: |
Events processed. Inspect `eventsFailed` and `failures[]` to detect
partial failure. For Meta, a batch is all-or-nothing (either every
event in a chunk succeeds, or every event in the chunk is listed
in failures). For Google, the API returns success/failure at the
request level only.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads] }
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 (fbtrace_id for Meta, requestId for Google) for debugging.
'400':
description: Invalid body (missing accountId/destinationId/events, malformed event shape).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required.
'404':
description: Account not found or not accessible.
/v1/accounts/{accountId}/conversion-destinations:
get:
operationId: listConversionDestinations
tags: [Ads]
summary: List destinations for the Conversions API
description: |
Returns the list of pixels (Meta) or conversion actions (Google)
accessible to the connected ads account. Use the returned `id` as
`destinationId` when posting to `POST /v1/ads/conversions`.
For Google, each destination's `type` reflects the conversion action's
category (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is locked to
the destination. For Meta, `type` is absent: pixels accept any event
name per request.
security:
- bearerAuth: []
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: SocialAccount ID (metaads or googleads).
responses:
'200':
description: Destinations listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads] }
destinations:
type: array
items:
type: object
properties:
id:
type: string
description: |
Destination identifier. Meta: pixel ID. Google:
conversion action resource name.
name: { type: string }
type:
type: string
description: |
Present when the platform locks event type to the
destination (Google conversion actions).
status: { type: string, enum: [active, inactive] }
'400':
description: Account's platform is not supported by the Conversions API.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads add-on required.
'404':
description: Account not found or not accessible.
/v1/whatsapp/conversions:
post:
operationId: sendWhatsAppConversion
tags: [WhatsApp, Ads]
summary: Send WhatsApp conversion event
description: |
Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`,
`Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's
Conversions API with `action_source = business_messaging` and
`messaging_channel = whatsapp`. The endpoint looks up the originating
CTWA click ID (`ctwa_clid`) captured on the first inbound message of
the conversation and replays it on every event so Meta can attribute
the conversion back to the Click-to-WhatsApp ad that drove the chat.
Configuration prerequisites on the WhatsApp account metadata:
- `metaCapiDatasetId`: the Meta Pixel/Dataset ID linked to the WABA.
- `connectedFacebookPageId`: the Facebook Page paired with the
WhatsApp Business number.
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` /
`connectedFacebookPageId` on the account) OR the resolved
conversation has no captured `ctwa_clid`.
/v1/ads/ctwa:
post:
operationId: createCtwaAd
tags: [Ads]
summary: Create Click-to-WhatsApp ad
description: >-
Creates a Click-to-WhatsApp (CTWA) ad on Meta. When tapped, the ad
opens a WhatsApp conversation with the business attached to the
supplied Facebook Page, and the full hierarchy (campaign, ad set,
creative, ad) 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. Prerequisites enforced by Meta (surfaced as platform_error on
failure), the Facebook Page must be paired with a verified WhatsApp
Business number, the WhatsApp Business Account must be
business-verified, and the Meta access token must carry ads_management.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, headline, body, budgetAmount, budgetType]
description: |
In addition to the `required` list, exactly one of `imageUrl`
or `video` must be supplied (they are mutually exclusive). The
route enforces this at the Zod boundary; OpenAPI's `required`
cannot express OR-required 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.
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 for image creatives. Mutually exclusive with
`video`. Required if `video` is not supplied.
video:
type: object
required: [url, thumbnailUrl]
properties:
url: { type: string, format: uri }
thumbnailUrl:
type: string
format: uri
description: |
Required by Meta for every video creative. Used as the
ad thumbnail.
description: |
Video creative. Mutually exclusive with `imageUrl`.
Required if `imageUrl` is not supplied.
budgetAmount:
type: number
minimum: 0
exclusiveMinimum: true
description: |
Budget amount in the ad account's currency major units
(e.g. dollars for USD, not cents). Must be > 0.
budgetType:
type: string
enum: [daily, lifetime]
currency:
type: string
minLength: 3
maxLength: 3
description: |
ISO 4217 currency code matching the ad account's currency
(e.g. `USD`). Optional; Meta infers from the ad account
when omitted.
endDate:
type: string
format: date-time
description: |
ISO 8601 datetime. Required when `budgetType` is `lifetime`.
countries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
description: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]`.
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
interests:
type: array
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
audienceId:
type: string
description: Custom audience ID to target.
advantageAudience:
type: integer
enum: [0, 1]
description: |
Meta's Advantage+ audience expansion. `0` (default) keeps
targeting strict; `1` lets Meta expand beyond the supplied
targeting when its delivery system finds better matches.
Always sent on CREATE (Meta requires it).
objective:
type: string
enum: [OUTCOME_ENGAGEMENT, OUTCOME_SALES, OUTCOME_LEADS]
description: |
Defaults to `OUTCOME_ENGAGEMENT` (the broadly-supported CTWA
objective). `OUTCOME_SALES` and `OUTCOME_LEADS` require
additional account configuration (Dataset linked to the WABA
for sales) and may be rejected by Meta if missing.
dsaBeneficiary:
type: string
maxLength: 100
description: |
Name of the legal entity benefiting from the ad.
Required by Meta when targeting EU users (DSA Article 26).
Not enforced at schema level; enforced server-side when targeting intersects EU member states.
dsaPayor:
type: string
maxLength: 100
description: |
Name of the legal entity paying for the ad.
Required by Meta when targeting EU users (DSA Article 26).
Note Meta API spelling: dsa_payor (not dsa_payer).
responses:
'201':
description: CTWA ad created and submitted to Meta for review.
content:
application/json:
schema:
type: object
properties:
ad: { type: object, description: The persisted Ad document. }
message: { type: string }
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on required. }
'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.