# Zernio API Documentation This document contains the complete API documentation for the Zernio API. --- # Changelog Stay up to date with the latest API changes and improvements import { Changelog } from '@/components/changelog'; Track all updates to the Zernio API. We announce significant changes here and on our [Telegram channel](https://t.me/zernio_dev) and [X (Twitter)](https://x.com/zernionews). --- # CLI Schedule posts, manage inbox, broadcasts, sequences, and automations across 14 platforms from the terminal. Built for developers and AI agents. import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; Schedule posts, manage inbox, broadcasts, sequences, and automations across 14 platforms from the terminal. Built for developers and AI agents. Outputs JSON by default. ## Setup ### Install the CLI ```bash npm install -g @zernio/cli ``` ### Authenticate ```bash zernio auth:login ``` This opens your browser where you can authorize the CLI. An API key is created automatically and saved to `~/.zernio/config.json`. You can optionally set a custom device name: ```bash zernio auth:login --device-name "my-server" ``` Running `auth:login` again from the same device replaces the existing key (no duplicates). ```bash zernio auth:set --key "sk_your-api-key" ``` Get your API key from [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys). ### Verify Setup ```bash zernio auth:check ``` This should confirm your API key is valid and display your account info. ## Quick Example ```bash # Schedule a post for tomorrow at 9am zernio posts:create \ --text "Hello from the Zernio CLI!" \ --accounts , \ --scheduledAt "2025-06-01T09:00:00Z" # List your DM conversations zernio inbox:conversations --platform instagram --pretty # Create a broadcast and send it zernio broadcasts:create --profileId --accountId --platform instagram --name "Summer Sale" --message "Check out our deals!" zernio broadcasts:add-recipients --useSegment zernio broadcasts:send ``` ## Commands ### Authentication | Command | Description | |---------|-------------| | `zernio auth:login` | Log in via browser (creates API key automatically) | | `zernio auth:set --key ` | Save API key manually | | `zernio auth:check` | Verify API key works | ### Profiles | Command | Description | |---------|-------------| | `zernio profiles:list` | List all profiles | | `zernio profiles:get ` | Get profile details | | `zernio profiles:create --name ` | Create a new profile | | `zernio profiles:update ` | Update a profile | | `zernio profiles:delete ` | Delete a profile | ### Accounts | Command | Description | |---------|-------------| | `zernio accounts:list` | List connected social accounts | | `zernio accounts:get ` | Get account details | | `zernio accounts:health` | Check account health status | ### Posts | Command | Description | |---------|-------------| | `zernio posts:create` | Create a new post | | `zernio posts:list` | List posts | | `zernio posts:get ` | Get post details | | `zernio posts:delete ` | Delete a post | | `zernio posts:retry ` | Retry a failed post | ### Analytics | Command | Description | |---------|-------------| | `zernio analytics:posts` | Get post performance metrics | | `zernio analytics:daily` | Get daily engagement stats | | `zernio analytics:best-time` | Get best times to post | ### Media | Command | Description | |---------|-------------| | `zernio media:upload ` | Upload a media file | ### Inbox Manage DM conversations, comments, and reviews across all connected accounts. | Command | Description | |---------|-------------| | `zernio inbox:conversations` | List DM conversations | | `zernio inbox:conversation ` | Get conversation details | | `zernio inbox:messages ` | Get messages in a conversation | | `zernio inbox:send ` | Send a DM | | `zernio inbox:comments` | List post comments across accounts | | `zernio inbox:post-comments ` | Get comments on a specific post | | `zernio inbox:reply ` | Reply to a comment | | `zernio inbox:reviews` | List reviews (Facebook, Google Business) | | `zernio inbox:review-reply ` | Reply to a review | ```bash # List Instagram DMs zernio inbox:conversations --platform instagram --pretty # Read messages and reply zernio inbox:messages --accountId zernio inbox:send --accountId --message "Thanks for reaching out!" # Reply to a comment zernio inbox:reply --accountId --message "Thank you!" --commentId ``` ### Contacts Cross-platform contact CRM with tags, custom fields, and channels. | Command | Description | |---------|-------------| | `zernio contacts:list` | List contacts | | `zernio contacts:create` | Create a contact | | `zernio contacts:get ` | Get contact details | | `zernio contacts:update ` | Update a contact | | `zernio contacts:delete ` | Delete a contact | | `zernio contacts:channels ` | List channels for a contact | | `zernio contacts:set-field ` | Set a custom field value | | `zernio contacts:clear-field ` | Clear a custom field value | | `zernio contacts:bulk-create` | Bulk create up to 1000 contacts from JSON | ```bash # Search contacts zernio contacts:list --search "john" --tag vip --pretty # Create a contact with a channel zernio contacts:create --profileId --name "John Doe" --accountId --platform instagram --platformIdentifier # Bulk create from file zernio contacts:bulk-create --profileId --accountId --platform instagram --file ./contacts.json ``` ### Broadcasts Send bulk messages to contacts across any inbox platform. | Command | Description | |---------|-------------| | `zernio broadcasts:list` | List broadcasts | | `zernio broadcasts:create` | Create a broadcast draft | | `zernio broadcasts:get ` | Get broadcast details with stats | | `zernio broadcasts:update ` | Update a broadcast (draft only) | | `zernio broadcasts:delete ` | Delete a broadcast (draft only) | | `zernio broadcasts:send ` | Send a broadcast immediately | | `zernio broadcasts:schedule ` | Schedule a broadcast for later | | `zernio broadcasts:cancel ` | Cancel a broadcast | | `zernio broadcasts:recipients ` | List broadcast recipients | | `zernio broadcasts:add-recipients ` | Add recipients to a broadcast | ```bash # Create and send a broadcast zernio broadcasts:create --profileId --accountId --platform instagram --name "Product Launch" --message "We just launched something new!" zernio broadcasts:add-recipients --contactIds ,, zernio broadcasts:send # Schedule a WhatsApp broadcast with a template zernio broadcasts:create --profileId --accountId --platform whatsapp --name "Order Update" --templateName "order_confirmation" zernio broadcasts:schedule --scheduledAt "2025-06-01T10:00:00Z" # Check delivery status zernio broadcasts:recipients --status delivered --pretty ``` ### Sequences Drip campaign sequences with timed message steps and automatic enrollment. | Command | Description | |---------|-------------| | `zernio sequences:list` | List sequences | | `zernio sequences:create` | Create a sequence | | `zernio sequences:get ` | Get sequence details with steps | | `zernio sequences:update ` | Update a sequence | | `zernio sequences:delete ` | Delete a sequence | | `zernio sequences:activate ` | Activate a sequence | | `zernio sequences:pause ` | Pause a sequence | | `zernio sequences:enroll ` | Enroll contacts into a sequence | | `zernio sequences:unenroll ` | Unenroll a contact | | `zernio sequences:enrollments ` | List enrollments | ```bash # Create a welcome sequence (steps defined in JSON file) zernio sequences:create --profileId --accountId --platform instagram --name "Welcome Series" --stepsFile ./steps.json # steps.json example: # [ # {"order": 1, "delayMinutes": 0, "message": {"text": "Welcome! Thanks for connecting."}}, # {"order": 2, "delayMinutes": 1440, "message": {"text": "Here are some tips..."}}, # {"order": 3, "delayMinutes": 4320, "message": {"text": "Check out our latest content!"}} # ] # Activate and enroll contacts zernio sequences:activate zernio sequences:enroll --contactIds , # Monitor enrollments zernio sequences:enrollments --status active --pretty ``` ### Automations Comment-to-DM automations for Instagram and Facebook. Auto-DM users who comment specific keywords on your posts. | Command | Description | |---------|-------------| | `zernio automations:list` | List comment-to-DM automations | | `zernio automations:create` | Create an automation | | `zernio automations:get ` | Get automation details with recent logs | | `zernio automations:update ` | Update an automation | | `zernio automations:delete ` | Delete an automation and all logs | | `zernio automations:logs ` | List trigger logs | ```bash # Auto-DM anyone who comments "info" or "link" zernio automations:create \ --profileId --accountId \ --platformPostId \ --name "Lead Magnet" \ --keywords "info,details,link" \ --dmMessage "Here's the link you asked for: https://example.com" \ --commentReply "Check your DMs!" # Trigger on ALL comments (no keyword filter) zernio automations:create \ --profileId --accountId \ --platformPostId \ --name "Engagement Boost" \ --dmMessage "Thanks for engaging! Here's a special offer..." # Disable an automation zernio automations:update --isActive false # View trigger logs zernio automations:logs --status sent --pretty ``` ## Configuration The CLI stores settings in `~/.zernio/config.json`. You can also use environment variables: | Variable | Description | Required | |----------|-------------|----------| | `ZERNIO_API_KEY` | Your API key | Yes | | `ZERNIO_API_URL` | Custom API endpoint | No | Environment variables override the config file. ## Supported Platforms Instagram, TikTok, X (Twitter), LinkedIn, Facebook, Threads, YouTube, Bluesky, Pinterest, Reddit, Snapchat, Telegram, and Google Business Profile. ## Links - [GitHub Repository](https://github.com/zernio-dev/zernio-cli) - [ClawHub Repository](https://clawhub.ai/mikipalet/zernio-cli) - [npm Package](https://www.npmjs.com/package/@zernio/cli) - [SDKs](/sdks) - [Zernio Dashboard](https://zernio.com) --- # Quickstart Get started with the Zernio API - authenticate, connect accounts, and schedule your first post in minutes. import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Zernio is a social media scheduling platform that lets you manage and publish content across all major platforms from a single API. Whether you're building a social media tool, automating your content workflow, or managing multiple brands, Zernio's API gives you complete control. **Base URL:** `https://zernio.com/api/v1` --- ## Install the SDK ```bash npm install @zernio/node ``` ```bash pip install zernio-sdk ``` ```bash go get github.com/zernio-dev/zernio-go ``` ```bash gem install zernio-sdk ``` ```xml com.zernio zernio-sdk 0.0.307 ``` ```groovy implementation 'com.zernio:zernio-sdk:0.0.307' ``` On [Maven Central](https://central.sonatype.com/artifact/com.zernio/zernio-sdk). For the latest version, see the [releases](https://github.com/zernio-dev/zernio-java/releases). ```bash composer require zernio-dev/zernio-php ``` ```bash dotnet add package Zernio ``` ```bash cargo add zernio ``` ## Authentication All API requests require an API key. The SDKs read from the `ZERNIO_API_KEY` environment variable by default. ### Getting Your API Key 1. Log in to your Zernio account at [zernio.com](https://zernio.com) 2. Go to **Settings → API Keys** 3. Click **Create API Key** 4. Copy the key immediately - you won't be able to see it again ### Set Up the Client ```typescript import Zernio from '@zernio/node'; const zernio = new Zernio(); // uses ZERNIO_API_KEY env var ``` ```python from zernio import Zernio client = Zernio() # uses ZERNIO_API_KEY env var ``` ```bash # Set your API key as an environment variable export ZERNIO_API_KEY="sk_..." # All requests use the Authorization header curl https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` **Key format:** `sk_` prefix + 64 hex characters (67 total). Keys are stored as SHA-256 hashes - they're only shown once at creation. **Security tips:** Use environment variables, create separate keys per app, and rotate periodically. You can also [manage keys via the API](/api-keys/list-api-keys). --- ## Key Concepts - **Profiles** - Containers that group social accounts together (think "brands" or "projects") - **Accounts** - Your connected social media accounts, belonging to profiles - **Posts** - Content to publish, schedulable to multiple accounts across platforms simultaneously - **Queue** - Optional recurring time slots for auto-scheduling posts --- ## Step 1: Create a Profile Profiles group your social accounts together. For example, you might have a "Personal Brand" profile with your Twitter and LinkedIn, and a "Company" profile with your business accounts. ```typescript const { profile } = await zernio.profiles.createProfile({ name: 'My First Profile', description: 'Testing the Zernio API' }); console.log('Profile created:', profile._id); ``` ```python result = client.profiles.create_profile( name="My First Profile", description="Testing the Zernio API" ) print(f"Profile created: {result.profile['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/profiles \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "My First Profile", "description": "Testing the Zernio API" }' ``` Save the `_id` value - you'll need it for the next steps. ## Step 2: Connect a Social Account Now connect a social media account to your profile. This uses OAuth, so it will redirect to the platform for authorization. ```typescript const { authUrl } = await zernio.connect.getConnectUrl({ platform: 'twitter', profileId: 'prof_abc123' }); // Redirect user to this URL to authorize console.log('Open this URL:', authUrl); ``` ```python result = client.connect.get_connect_url( platform="twitter", profile_id="prof_abc123" ) print(f"Open this URL: {result.auth_url}") ``` ```bash curl "https://zernio.com/api/v1/connect/twitter?profileId=prof_abc123" \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` Open the URL in a browser to authorize Zernio to access your Twitter account. After authorization, you'll be redirected back and the account will be connected. ### Available Platforms Replace `twitter` with any of these: ## Step 3: Get Your Connected Accounts After connecting, list your accounts to get the account ID: ```typescript const { accounts } = await zernio.accounts.listAccounts(); for (const account of accounts) { console.log(`${account.platform}: ${account._id}`); } ``` ```python result = client.accounts.list_accounts() for account in result.accounts: print(f"{account['platform']}: {account['_id']}") ``` ```bash curl "https://zernio.com/api/v1/accounts" \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` Save the account `_id` - you need it to create posts. ## Step 4: Schedule Your First Post Now you can schedule a post! Here's how to schedule a tweet for tomorrow at noon: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello world! This is my first post from the Zernio API', scheduledFor: '2024-01-16T12:00:00', timezone: 'America/New_York', platforms: [ { platform: 'twitter', accountId: 'acc_xyz789' } ] }); console.log('Post scheduled:', post._id); ``` ```python result = client.posts.create_post( content="Hello world! This is my first post from the Zernio API", scheduled_for="2024-01-16T12:00:00", timezone="America/New_York", platforms=[ {"platform": "twitter", "accountId": "acc_xyz789"} ] ) print(f"Post scheduled: {result.post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello world! This is my first post from the Zernio API", "scheduledFor": "2024-01-16T12:00:00", "timezone": "America/New_York", "platforms": [ {"platform": "twitter", "accountId": "acc_xyz789"} ] }' ``` Your post is now scheduled and will publish automatically at the specified time. ## Posting to Multiple Platforms You can post to multiple platforms at once. Just add more entries to the `platforms` array: ```typescript const { post } = await zernio.posts.createPost({ content: 'Cross-posting to all my accounts!', scheduledFor: '2024-01-16T12:00:00', timezone: 'America/New_York', platforms: [ { platform: 'twitter', accountId: 'acc_twitter123' }, { platform: 'linkedin', accountId: 'acc_linkedin456' }, { platform: 'bluesky', accountId: 'acc_bluesky789' } ] }); ``` ```python result = client.posts.create_post( content="Cross-posting to all my accounts!", scheduled_for="2024-01-16T12:00:00", timezone="America/New_York", platforms=[ {"platform": "twitter", "accountId": "acc_twitter123"}, {"platform": "linkedin", "accountId": "acc_linkedin456"}, {"platform": "bluesky", "accountId": "acc_bluesky789"} ] ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Cross-posting to all my accounts!", "scheduledFor": "2024-01-16T12:00:00", "timezone": "America/New_York", "platforms": [ {"platform": "twitter", "accountId": "acc_twitter123"}, {"platform": "linkedin", "accountId": "acc_linkedin456"}, {"platform": "bluesky", "accountId": "acc_bluesky789"} ] }' ``` ## Publishing Immediately To publish right now instead of scheduling, use `publishNow: true`: ```typescript const { post } = await zernio.posts.createPost({ content: 'This posts immediately!', publishNow: true, platforms: [ { platform: 'twitter', accountId: 'acc_xyz789' } ] }); ``` ```python result = client.posts.create_post( content="This posts immediately!", publish_now=True, platforms=[ {"platform": "twitter", "accountId": "acc_xyz789"} ] ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "This posts immediately!", "publishNow": true, "platforms": [ {"platform": "twitter", "accountId": "acc_xyz789"} ] }' ``` ## Creating a Draft To save a post without publishing or scheduling, omit both `scheduledFor` and `publishNow`: ```typescript const { post } = await zernio.posts.createPost({ content: 'I will finish this later...', platforms: [ { platform: 'twitter', accountId: 'acc_xyz789' } ] }); ``` ```python result = client.posts.create_post( content="I will finish this later...", platforms=[ {"platform": "twitter", "accountId": "acc_xyz789"} ] ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "I will finish this later...", "platforms": [ {"platform": "twitter", "accountId": "acc_xyz789"} ] }' ``` ## What's Next? - **[Platform Guides](/platforms)** - Learn platform-specific features and requirements - **[Upload media](/guides/media-uploads)** - Add images and videos to your posts - **[Set up a queue](/queue/list-queue-slots)** - Create recurring posting schedules - **[View analytics](/analytics/get-analytics)** - Track how your posts perform - **[Invite team members](/invites/create-invite-token)** - Collaborate with your team - **[CLI](/cli)** - Manage posts from the terminal --- # MCP Connect AI assistants to the Zernio API using the Model Context Protocol import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; Connect AI assistants to the full Zernio API using the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Schedule posts, manage ads, automate DMs, run sequences, and more across 14 platforms, all through natural language. The Zernio MCP server exposes 300+ tools auto-generated from the Zernio OpenAPI spec, covering every API endpoint. It works with Claude, Claude Code, ChatGPT, Cursor, Windsurf, and any MCP-compatible client. ## Use Zernio in your AI client Zernio runs as a single hosted MCP server at `https://mcp.zernio.com/mcp`. Pick your client: **Claude (web, desktop, mobile)** — Settings → Connectors → Add custom connector, then enter `https://mcp.zernio.com/mcp`. You sign in with your Zernio account (OAuth); no API key needed. Full steps in [Setup](#setup) below. **Claude Code** — install the plugin: ``` /plugin marketplace add zernio-dev/zernio-claude-plugin /plugin install zernio@zernio ``` It prompts once for your [Zernio API key](https://zernio.com/dashboard/api-keys) (stored in your system keychain). **ChatGPT** — add a connector pointing at `https://mcp.zernio.com/mcp` and authorize with your Zernio account (OAuth). **Any MCP client / the MCP Registry** — Zernio is published to the [official MCP Registry](https://registry.modelcontextprotocol.io) as `com.zernio/zernio`. Most MCP clients can add the remote server directly: ``` https://mcp.zernio.com/mcp ``` Authenticate with OAuth (sign in with Zernio) or an `Authorization: Bearer ` header. ## What You Can Do Ask your AI assistant things like: - *"Post 'Hello world!' to Twitter"* - *"Schedule a LinkedIn post for tomorrow at 9am"* - *"Show my connected accounts"* - *"Cross-post this to Twitter and LinkedIn"* - *"Post this image to Instagram"* (with browser upload flow) - *"List my ad campaigns"* - *"Send a WhatsApp broadcast to my contacts"* - *"Show my inbox conversations"* - *"Create a comment automation for my latest post"* ## Setup Choose your preferred setup method: The hosted MCP server at `mcp.zernio.com` requires no local installation. Just configure your client to connect over HTTP. ### Get Your API Key Go to [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys) and create an API key. ### Configure Your Client #### Claude Desktop Claude Desktop's `claude_desktop_config.json` only supports local stdio servers (`command` + `args`). Remote servers like `mcp.zernio.com` are added through the GUI instead. The `url` + `headers` form for `claude_desktop_config.json` shown in older guides does not work in current Claude Desktop builds — that file is stdio-only. Open Claude Desktop and go to **Settings → Connectors → Add custom connector**: - **Name:** Zernio - **URL:** `https://mcp.zernio.com/mcp` Click **Add**. Claude redirects you to sign in and authorize with your Zernio account (OAuth), then the connector activates. You don't paste an API key for this path. The sign-in flow handles authorization. If you tried this before and saw "Couldn't reach the MCP server", remove the connector and add it again. Claude caches a failed authorization attempt, so a stale failure persists until you re-add it. If you'd rather configure it in `claude_desktop_config.json`, use the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio bridge: ``` ~/Library/Application Support/Claude/claude_desktop_config.json ``` ``` %APPDATA%\Claude\claude_desktop_config.json ``` ```json { "mcpServers": { "zernio": { "command": "npx", "args": [ "-y", "mcp-remote@latest", "https://mcp.zernio.com/mcp", "--header", "Authorization: Bearer your_api_key_here" ] } } } ``` Paste the full `sk_...` API key directly into the `--header` value. Earlier examples used an `${AUTH}` env-var indirection, but Claude Desktop on Windows does not always expand env vars into `npx` args, which leaves the header empty and causes a misleading 404 / OAuth-discovery error. #### Cursor Add to your project's `.cursor/mcp.json`: ```json { "zernio": { "type": "http", "url": "https://mcp.zernio.com/mcp", "headers": { "Authorization": "Bearer your_api_key_here" } } } ``` ### Restart Your Client Close and reopen your AI client. The Zernio integration will be available immediately. Run the MCP server locally using uvx (no install needed). ### Install uv ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` ```powershell powershell -c "irm https://astral.sh/uv/install.ps1 | iex" ``` ### Get Your API Key Go to [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys) and create an API key. ### Configure Claude Desktop Open Claude Desktop settings and go to **Developer** > **Edit Config**: ![Claude Desktop Developer Settings](/docs-static/claude-desktop-config.png) Edit `claude_desktop_config.json`: ```json { "mcpServers": { "zernio": { "command": "uvx", "args": ["--from", "zernio-sdk[mcp]", "zernio-mcp"], "env": { "ZERNIO_API_KEY": "your_api_key_here" } } } } ``` ### Restart Claude Desktop Close and reopen Claude Desktop. The Zernio integration will be available immediately. Install the MCP server via pip. ```bash pip install zernio-sdk[mcp] ``` Configure Claude Desktop: ```json { "mcpServers": { "zernio": { "command": "zernio-mcp", "env": { "ZERNIO_API_KEY": "your_api_key_here" } } } } ``` `pip install zernio-sdk[mcp]` puts the `zernio-mcp` command on your PATH. If Claude Desktop can't find it, use the absolute path (run `which zernio-mcp` to get it) or the uvx method above. Replace `your_api_key_here` with your actual API key. ## Available Tools The MCP server exposes 280+ tools covering the entire Zernio API. Here are the main categories: ### Core Tools These hand-crafted tools provide the best experience for common tasks: | Tool | Description | |------|-------------| | `accounts_list` | Show all connected social media accounts | | `accounts_get` | Get account details for a specific platform | | `profiles_list` / `get` / `create` / `update` / `delete` | Manage profiles | | `posts_list` / `get` / `create` / `update` / `delete` | Manage posts | | `posts_publish_now` | Publish a post immediately | | `posts_cross_post` | Post to multiple platforms at once | | `posts_retry` / `posts_list_failed` / `posts_retry_all_failed` | Handle failed posts | | `media_generate_upload_link` | Get a link to upload media files | | `media_check_upload_status` | Check if media upload is complete | | `docs_search` | Search the Zernio API documentation | ### Auto-Generated Tools These tools are auto-generated from the OpenAPI spec and cover every endpoint: | Category | Examples | |----------|---------| | **Ads** | `list_ad_campaigns`, `create_standalone_ad`, `boost_post`, `get_ad_analytics`, `search_ad_interests` | | **WhatsApp** | `create_whats_app_broadcast`, `send_whats_app_bulk`, `get_whats_app_contacts`, `create_whats_app_template` | | **Inbox** | `list_inbox_conversations`, `send_inbox_message`, `reply_to_inbox_post`, `list_inbox_reviews` | | **Contacts** | `list_contacts`, `create_contact`, `bulk_create_contacts`, `set_contact_field_value` | | **Sequences** | `create_sequence`, `enroll_contacts`, `activate_sequence`, `pause_sequence` | | **Comment Automations** | `create_comment_automation`, `list_comment_automation_logs` | | **Broadcasts** | `create_broadcast`, `schedule_broadcast`, `send_broadcast` | | **Analytics** | `get_analytics`, `get_best_time_to_post`, `get_instagram_demographics`, `get_you_tube_daily_views` | | **Connect** | `get_connect_url`, `handle_o_auth_callback`, `connect_bluesky_credentials` | | **Webhooks** | `create_webhook_settings`, `get_webhook_logs`, `test_webhook` | The auto-generated tools update automatically when new API endpoints are added. You always have access to the latest Zernio API features. ## Tool Reference Detailed parameters for the core tools. ### Posts **Multi-account users (agencies, multi-client setups):** when you have more than one account on the same platform, you must pass `account_id` (or `profile_id`) to disambiguate. The write tools below return an error with the candidate account IDs if the selection is ambiguous, instead of silently picking one. Call `accounts_list` first to discover IDs. Available in `zernio-sdk` 1.4.0+. #### `posts_create` Create a social media post. Can be saved as DRAFT, SCHEDULED, or PUBLISHED immediately. **Choose the correct mode based on user intent:** - **DRAFT MODE** (`is_draft=true`): Use when user says "draft", "save for later", "don't publish". Post is saved but NOT published. - **IMMEDIATE MODE** (`publish_now=true`): Use when user says "publish now", "post now", "immediately". Post goes live right away. - **SCHEDULED MODE** (default): Use when user says "schedule", "in X minutes/hours". Post is scheduled for future publication. | Parameter | Type | Description | Required | Default | |-----------|------|-------------|----------|---------| | `content` | `string` | The post text/content | Yes | - | | `platform` | `string` | Target platform: twitter, instagram, linkedin, tiktok, bluesky, facebook, youtube, pinterest, threads, googlebusiness, telegram, snapchat | Yes | - | | `account_id` | `string` | Specific account to post from. Required when the user has multiple accounts on this platform. Call `accounts_list` to find IDs. | No | `""` | | `profile_id` | `string` | Scope account auto-resolution to one profile (e.g. one client in an agency setup). Use when `account_id` is unknown but the target profile is. | No | `""` | | `is_draft` | `boolean` | Set to true to save as DRAFT (not published, not scheduled) | No | `false` | | `publish_now` | `boolean` | Set to true to publish IMMEDIATELY | No | `false` | | `schedule_minutes` | `integer` | Minutes from now to schedule. Only used when is_draft=false AND publish_now=false | No | `60` | | `media_urls` | `string` | Comma-separated URLs of media files to attach (images, videos) | No | `""` | | `title` | `string` | Post title (required for YouTube, recommended for Pinterest) | No | `""` | #### `posts_publish_now` Publish a post immediately to a platform. Convenience wrapper around `posts_create` with `publish_now=true`. | Parameter | Type | Description | Required | Default | |-----------|------|-------------|----------|---------| | `content` | `string` | The post text/content | Yes | - | | `platform` | `string` | Target platform | Yes | - | | `account_id` | `string` | Specific account ID. Required when the user has multiple accounts on this platform. | No | `""` | | `profile_id` | `string` | Scope auto-resolution to a single profile when `account_id` is unknown. | No | `""` | | `media_urls` | `string` | Comma-separated URLs of media files to attach | No | `""` | #### `posts_cross_post` Post the same content to multiple platforms at once. To target multiple accounts of the *same* platform in one call, repeat the platform: `platforms="twitter,twitter"`, `account_ids="acc_a,acc_b"`. | Parameter | Type | Description | Required | Default | |-----------|------|-------------|----------|---------| | `content` | `string` | The post text/content | Yes | - | | `platforms` | `string` | Comma-separated list of platforms (e.g., 'twitter,linkedin,bluesky'). Repeat a platform to target multiple accounts of it. | Yes | - | | `account_ids` | `string` | Comma-separated account IDs, parallel to `platforms`. Empty positions fall back to profile/auto-resolution. Required for multi-account users to disambiguate. | No | `""` | | `profile_id` | `string` | Scope auto-resolution to one profile when `account_ids` is empty. | No | `""` | | `is_draft` | `boolean` | Set to true to save as DRAFT (not published) | No | `false` | | `publish_now` | `boolean` | Set to true to publish IMMEDIATELY to all platforms | No | `false` | | `media_urls` | `string` | Comma-separated URLs of media files to attach | No | `""` | #### `posts_create_post` Autogenerated tool that mirrors the full `createPost` REST surface. Use this instead of `posts_create` when you need per-target customisation that the simplified tool doesn't expose: `customContent` (different caption per platform), `customMedia` (different attachments per target), per-target `scheduledFor`, or `platformSpecificData` (TikTok privacy, YouTube category, etc.). The `platforms` argument is a JSON-encoded array of objects with `platform`, `accountId`, and any per-target overrides. Available in `zernio-sdk` 1.4.0+. #### `posts_list` | Parameter | Type | Description | Required | Default | |-----------|------|-------------|----------|---------| | `status` | `string` | Filter by status: draft, scheduled, published, failed | No | `""` | | `limit` | `integer` | Maximum number of posts to return | No | `10` | #### `posts_get` / `posts_delete` / `posts_retry` | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `post_id` | `string` | The post ID | Yes | #### `posts_update` | Parameter | Type | Description | Required | Default | |-----------|------|-------------|----------|---------| | `post_id` | `string` | The post ID to update | Yes | - | | `content` | `string` | New content | No | `""` | | `scheduled_for` | `string` | New schedule time (ISO string) | No | `""` | | `title` | `string` | New title | No | `""` | ### Media #### `media_generate_upload_link` Generate a unique upload URL for the user to upload files via browser. The flow is: 1. Call this tool to get an upload URL 2. Ask the user to open the URL in their browser 3. User uploads files through the web interface 4. Call `media_check_upload_status` to get the uploaded file URLs 5. Use those URLs when creating the post with `posts_create` #### `media_check_upload_status` | Parameter | Type | Description | Required | |-----------|------|-------------|----------| | `token` | `string` | The upload token from media_generate_upload_link | Yes | ## Uploading Images & Videos Since AI clients can't access files on your computer directly, we use a browser upload flow: ### Ask to Create an Upload Link Say something like: *"I want to post an image to Instagram"* The assistant will give you a unique upload URL. ### Upload Your File Open the URL in your browser. You'll see a simple upload page where you can drag & drop your image or video. ### Confirm Upload Just say *"done"* or *"uploaded"*. The assistant will check the status and create your post with the media attached. **Supported file types:** - Images: JPG, PNG, WebP, GIF - Videos: MP4, MOV, WebM - Documents: PDF **Maximum file size:** 5GB ## Example Conversations ### Simple Post > **You:** Post "Just launched our new feature!" to Twitter > > **Assistant:** Published to Twitter (@yourhandle) > Post ID: post_abc123 ### Scheduled Post > **You:** Schedule a LinkedIn post for tomorrow at 9am: "Excited to announce our Series A funding!" > > **Assistant:** Scheduled for LinkedIn (@yourcompany) > Post ID: post_xyz789 > Scheduled: 2025-03-15 09:00 ### Cross-Posting > **You:** Post "Big news coming soon..." to Twitter and LinkedIn > > **Assistant:** Published to: twitter, linkedin > Post ID: post_multi456 ### With Image > **You:** I want to post a product photo to Instagram > > **Assistant:** Upload link generated! Open this link in your browser to upload: https://... > > **You:** done > > **Assistant:** Upload completed! 1 file uploaded. Creating the post... > Published to Instagram with 1 media file ## Troubleshooting ### "Command not found: uvx" (local setup only) Make sure uv is installed and in your PATH: ```bash # Check if installed uvx --version # If not, install it curl -LsSf https://astral.sh/uv/install.sh | sh ``` You may need to restart your terminal or add uv to your PATH. ### "Invalid API key" 1. Check your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys) 2. Make sure you copied it correctly (no extra spaces) 3. Verify the key is active ### "No accounts connected" You need to connect social media accounts at [zernio.com](https://zernio.com) before you can post. ### Changes not taking effect After editing your client's MCP configuration, you must restart the client completely. ## Links - [Zernio Dashboard](https://zernio.com) - [Get API Key](https://zernio.com/dashboard/api-keys) - [Python SDK](https://github.com/zernio-dev/zernio-python) - [SDKs](/sdks) - [MCP Protocol](https://modelcontextprotocol.io/) --- # Pricing Pay-per-account pricing — first 2 accounts free, then graduated rates with everything included Zernio is **usage-based**. You pay per connected social account, with everything (Analytics, Inbox, Ads, unlimited posts) included on every paid account. No plan tiers, no add-ons, no profile limits. ## Free **$0/month** — first 2 connected accounts are always free. No credit card required. Includes Analytics, Inbox, Ads, and full API access on every account. --- ## Usage Pay only for the accounts you connect, with all features bundled. Add or remove accounts at any time — billing is prorated by the day. | Connected accounts | Price per account / month | |---|---| | 1–2 | **Free** (covered by a recurring monthly credit) | | 3–10 | **$6** each | | 11–100 | **$3** each | | 101–2,000 | **$1** each | | 2,001+ | [Custom](https://zernio.com/enterprise) | The pricing is **graduated**: each account is billed at the rate of its tier band, not at one flat tier. So 20 connected accounts costs `$6 × 8 + $3 × 10 = $78/month` (the first 2 are free). ### Try the calculator A worked example for common scales: | You connect | Monthly cost | |---|---| | 5 accounts | $18 | | 20 accounts | $78 | | 50 accounts | $168 | | 100 accounts | $258 | | 500 accounts | $658 | | 1,000 accounts | $1,158 | | 2,000 accounts | $2,158 | For more than **2,000 accounts**, get in touch at [zernio.com/enterprise](https://zernio.com/enterprise) for a custom contract. --- ## What's included Everything is bundled with every paid account — no add-ons, no upgrades. | Feature | Free | Paid | |---|:---:|:---:| | Full API access | ✓ | ✓ | | Unlimited posts | ✓ | ✓ | | All 14 platforms | ✓ | ✓ | | **Analytics** (post metrics, reach, insights) | ✓ | ✓ | | **Inbox** (DMs, comments, reviews) | ✓ | ✓ | | **Ads** (Meta, TikTok, LinkedIn, Pinterest, X, Google) | ✓ | ✓ | No profile limits, no post-per-month caps, no per-feature pricing. Connect accounts, schedule posts, use every feature. --- ## Other charges A couple of items aren't priced per account. They appear as separate line items on your invoice when used. ### WhatsApp Business numbers **From $2 per WhatsApp number / month**, separate from the social-account price. US numbers are $2/mo; numbers in other countries are priced per country (typically $2 to $25/mo) and are shown before you confirm the purchase. Regulated countries require a one-time identity verification (KYC) before the number is provisioned. Separately, Meta bills its own usage fees directly to your WhatsApp Business account, not through Zernio: per delivered template message, and a per-minute rate for outbound calls. Inbound calls and in-window service messages are free. ### X (Twitter) API X's pay-per-call API costs are **passed through at exact rate, with zero markup**. Each X API operation (post, read, DM, etc.) is metered against your account at X's published price (e.g. $0.005 per Posts: Read, $0.015 per Content: Create). See [docs.x.com/x-api/getting-started/pricing](https://docs.x.com/x-api/getting-started/pricing) for the full table. X usage applies only when you have an X account connected. Customers can set a monthly X spend cap from the dashboard to avoid surprise bills. --- ## How billing works - We charge your card automatically as you accrue usage. The first time your accrued usage reaches a small fraud-protection threshold (starting at $10), your card is charged for that amount and the threshold doubles for next time. Established customers see fewer, larger charges; brand-new paying customers see one quick small charge to validate the card. - Each invoice is itemized — you see exactly which accounts, WhatsApp numbers, and X-API calls you're paying for. - You can connect or disconnect accounts at any time. Billing prorates by the day. - Powered by [Metronome](https://metronome.com) on top of Stripe for invoicing. ### Daily proration mechanics Accounts are metered per day, not per month-snapshot. Every day, our system records which accounts are connected and reports them to the billing engine — one event per active account per day. The per-day mechanism means you only pay for the days each account was actually active. For a single account in a single tier, the cost works out to: ``` account_cost = tier_rate × (days_active / days_in_month) ``` So a $6/month account connected for 15 days costs **$3** that month. If you connect on day 1 and disconnect on day 15, you're billed for the half you used. #### How it interacts with graduated tiers Because pricing is graduated ($6 / $3 / $1), the rate isn't fixed per-account — it depends on your **total billable units for the month**, computed as: ``` billable_units = (sum of all account-days in the month) ÷ days_in_month ``` That total is then run through the graduated tier ladder (first 10 units at $6, next 90 at $3, next 1,900 at $1). You never get charged tier-1 prices for units that fall into tier 2 or 3 — partial usage in higher tiers gets the lower rate. > **Mental model:** at the end of the month we add up every "account-day" (one per account per day connected), divide by 30, and run the result through the rate ladder. So 100 accounts × 3 days costs the same as 10 accounts × 30 days. The graduated rates apply to the *total*, not to individual accounts. The calculator reflects the exact math the billing engine uses: account-days → divide by 30 → graduated tiers → minus $12 free-tier credit. #### Worked example A customer has 10 accounts connected for the full month. In week 2 they connect an 11th account, then disconnect it in week 3. In week 4 they connect a 12th, different account, for the rest of the month. **Step 1 — count account-days:** | Account group | Days active | Account-days | |---|---|---| | 10 baseline accounts | 30 days each | 300 | | 1 account (week 2 → 3) | 7 days | 7 | | 1 account (week 4 → end) | 7 days | 7 | | **Total** | | **314 account-days** | **Step 2 — convert to billable units:** ``` 314 ÷ 30 = 10.467 billable units ``` **Step 3 — apply graduated tiers:** | Tier | Units in this tier | Rate | Cost | |---|---|---|---| | Tier 1 (1-10) | 10 | $6.00 | $60.00 | | Tier 2 (11-100) | 0.467 | $3.00 | $1.40 | | **Total gross** | | | **$61.40** | **Step 4 — apply the free-tier credit:** | Item | Amount | |---|---| | Total gross | $61.40 | | Free-tier credit (flat per month) | −$12.00 | | **Net invoice** | **$49.40** | The total bill is **not** "11 accounts" or "12 accounts" — it's the sum of all account-days converted into billable units, then run through the graduated rates. The 11th and 12th accounts (which only existed for partial weeks) push the total just past the tier-1 boundary, so their fractional contribution is priced at tier 2's $3 rate, not tier 1's $6. #### Common questions - **What window does Zernio measure activity in?** Calendar month — account-days accumulate from the 1st to the last day of each month, then flush into a new period. - **If I disconnect and reconnect the same account, does it count twice?** Each day it's active counts. If it's active for 10 days, disconnected for 5, then reconnected for 10 more (same calendar month), that's 20 account-days for that account. - **What if I connect a brand-new account mid-month?** Charged from the day it's connected onward, prorated through end-of-month. - **Which accounts pay tier-1 vs tier-2 rates?** The graduated rate isn't tied to individual accounts — it applies to the **total billable units**. The first 10 units of usage are at $6, units 11-100 at $3, and so on. So if your monthly total works out to 12.5 billable units, the first 10 are billed at $6 and the remaining 2.5 at $3. - **What about the free-tier credit?** Each calendar month grants $12 of credit (covers the first 2 accounts at the $6 tier). Credit applies to the gross monthly total — it's a flat per-month grant, not per-account. --- ## Get started Sign up at [zernio.com](https://zernio.com) — first 2 accounts free, no credit card required. --- # Refer & earn Earn 20% recurring commission for 12 months when you refer new customers to Zernio. # Zernio Affiliate Program Terms Zernio has an affiliate program — refer new customers and earn 20% recurring commission for 12 months. Head to our [affiliate page](https://partners.dub.co/zernio) for the full details and to apply. The terms below cover how it all works. **Effective date:** The date you submit your application and accept these terms. This agreement is between Zernio ("we," "us," "the Company") and you, the approved partner ("you," "Partner"). By applying to the Zernio Partner Program ("Program") and accepting these terms, you agree to the following. ## 1. Definitions **Referral** — a potential customer you introduce to Zernio through your unique partner link. **Qualified Referral** — a Referral that meets all of the following criteria: - A first-time Zernio customer who has not previously held an account in our system. - Completes a qualifying paid subscription (connecting 3 or more social accounts, as the first 2 are free). - Full, valid payment is received by the Company. - Remains an active, paying customer for at least 30 days after their initial payment. The determination of whether a Referral qualifies rests with the Company. ## 2. Eligibility and Approval The Program is application-based. We review each application individually and reserve the right to approve or decline at our discretion. We may also end a partnership if these terms are not being followed. ## 3. Partner Responsibilities As a partner, we ask that you: - Represent Zernio's products and capabilities accurately - Share your partner link only through authorized methods and channels - Comply with all applicable advertising, marketing, and data protection laws in your jurisdiction - Follow the brand guidelines we provide upon approval ## 4. Program Guidelines To keep the Program fair and sustainable for everyone, the following activities are **not permitted**: - Bidding on Zernio brand terms in paid search, or running paid advertising that competes directly with Zernio's own campaigns - Listing Zernio on coupon, deal, or discount aggregator sites without prior approval - Using automated, incentivized, or artificially inflated traffic methods (including bots, cookie stuffing, or misleading redirects) - Referring accounts that you own, operate, or control - Operating sub-affiliate arrangements or reselling access to the Program - Representing yourself as an employee, agent, or official representative of Zernio - Misrepresenting Zernio's product capabilities or pricing to drive signups We understand that most partners would never consider these things, but having them written down protects everyone involved. Violations may result in removal from the Program and forfeiture of unpaid commissions. ## 5. Revenue Share ### Commission Partners earn a **20% revenue share** on qualifying subscription fees paid by each Qualified Referral, for up to **12 months** from the date of their first payment or until the customer cancels, whichever comes first. ### What's included in the calculation Commission is calculated on Zernio's own account-based subscription revenue. The following are excluded: - Taxes, discounts, and promotional credits - X/Twitter API passthrough costs (billed at exact platform rates with no Zernio margin) - WhatsApp passthrough charges ## 6. Payment Terms - **Platform:** Payouts are processed through Dub Partners and facilitated via Stripe. - **Schedule:** Commissions accrue monthly and are issued at the end of each calendar month. - **Holding period:** The first month's commission for each new Qualified Referral is held for 30 days after month-end, allowing time to verify eligibility and confirm no refund or cancellation occurred. - **Minimum payout:** $10 — balances below this threshold carry forward to the following month. - **Delivery:** Payments are sent within 30 days following month-end. ## 7. Refunds and Adjustments If a Qualified Referral cancels, receives a refund, initiates a chargeback, or is found to have been obtained through methods that don't comply with these terms, the associated commissions may be adjusted or withheld. We will notify you if this happens. ## 8. Taxes Partners are responsible for all applicable taxes on earnings received through the Program. ## 9. Intellectual Property Any trademarks, logos, screenshots, or promotional materials we provide remain the property of Zernio. You're welcome to use them to promote the Program in accordance with our [brand guidelines](https://zernio.com/press), but no ownership rights are transferred. If we update or retire specific materials, we'll ask you to update accordingly. ## 10. Term and Termination This agreement remains in effect from the date of your acceptance until either party ends it. - You may leave the Program at any time. - We may end the partnership at any time, with or without cause, by providing notice. In cases of serious or repeated violations of these terms, we may terminate immediately. Upon termination, any earned and verified commissions will still be paid out according to the normal schedule. Commissions that haven't yet cleared the 30-day holding period will be reviewed and paid if eligible. ## 11. Changes to the Program We may update these terms, adjust commission rates, or modify Program mechanics over time. When we do: - We'll provide at least 15 days' notice by email before changes take effect - Continued participation after the notice period constitutes acceptance of the updated terms We're committed to making changes thoughtfully and transparently. ## 12. Disputes If you believe a commission was calculated incorrectly, please let us know in writing within 30 days of the relevant payment. We'll work together to resolve it. ## 13. General Provisions - **Governing law:** This agreement is governed by the laws of Spain. - **Independent relationship:** Partners are independent and are not employees, agents, or contractors of Zernio. Nothing in this agreement creates an employment or agency relationship. - **Non-exclusivity:** This agreement is non-exclusive. Both parties are free to enter into similar arrangements with others. - **Entire agreement:** This document represents the complete agreement between both parties regarding the Program and supersedes any prior discussions or understandings. - **Severability:** If any provision is found to be unenforceable, the remaining provisions continue in full effect. ## 14. Contact For questions about the Program, reach us at **affiliates@zernio.com**. --- By submitting your application to the Zernio Partner Program and accepting these terms, you confirm that you have read, understood, and agree to this agreement. --- # SDKs Official Zernio API client libraries for Node.js, Python, Go, Ruby, Java, PHP, .NET, and Rust. Post to 14+ social platforms. import { Cards, Card } from 'fumadocs-ui/components/card'; ## Official SDKs } title="Node.js" description="github.com/zernio-dev/zernio-node" href="https://github.com/zernio-dev/zernio-node" /> } title="Python" description="github.com/zernio-dev/zernio-python" href="https://github.com/zernio-dev/zernio-python" /> } title="Go" description="github.com/zernio-dev/zernio-go" href="https://github.com/zernio-dev/zernio-go" /> } title="Ruby" description="github.com/zernio-dev/zernio-ruby" href="https://github.com/zernio-dev/zernio-ruby" /> } title="Java" description="github.com/zernio-dev/zernio-java" href="https://github.com/zernio-dev/zernio-java" /> } title="PHP" description="github.com/zernio-dev/zernio-php" href="https://github.com/zernio-dev/zernio-php" /> } title=".NET" description="github.com/zernio-dev/zernio-dotnet" href="https://github.com/zernio-dev/zernio-dotnet" /> } title="Rust" description="github.com/zernio-dev/zernio-rust" href="https://github.com/zernio-dev/zernio-rust" /> ## Chat SDK Adapter Build chatbots that work across all Zernio-supported messaging platforms through a single [Chat SDK](https://chat-sdk.dev) integration. } title="@zernio/chat-sdk-adapter" description="Vendor official Chat SDK adapter for Instagram, Facebook, Telegram, WhatsApp, X, Bluesky & Reddit" href="https://github.com/zernio-dev/chat-sdk-adapter" /> See the full [Chat SDK integration guide](/resources/integrations/chat-sdk) for setup instructions and examples. ## OpenAPI } title="OpenAPI Spec" description="zernio.com/openapi.yaml" href="https://zernio.com/openapi.yaml" /> --- # Webhooks How Zernio webhook deliveries work and the payload sent for each event. import { Callout } from 'fumadocs-ui/components/callout'; ## How to think about webhooks - Subscribe only to the events you actually handle. - Treat each delivery as an event notification, not a full source of truth sync. - Use the webhook event ID as your deduplication key. - Verify the `X-Zernio-Signature` header when you configure a webhook secret. - Expect fast acknowledgement from your endpoint and move heavier processing to async jobs. ## Delivery flow 1. Create a webhook endpoint with [Create webhook settings](/webhooks/create-webhook-settings). 2. Choose the events you want to subscribe to. 3. Receive a `POST` request from Zernio whenever one of those events occurs. 4. Return a `2xx` response after you have accepted the payload. 5. Use [Test webhook](/webhooks/test-webhook) and [Webhook logs](https://zernio.com/dashboard/webhooks) to validate your integration. ## Delivery retries A delivery is considered successful when your endpoint returns a `2xx` response within 5 seconds. Any other outcome (non-`2xx` status, request timeout, connection error) triggers a retry on an exponential backoff schedule capped at 24 hours. Up to 7 attempts are made per event. The full schedule, measured from the moment the previous attempt finished: | Attempt | Delay before this attempt | Cumulative time since the first attempt | | --- | --- | --- | | 1 | immediate | 0 | | 2 | 10s | ~10s | | 3 | 1m 40s | ~1m 50s | | 4 | 16m 40s | ~18m 30s | | 5 | 2h 46m 40s | ~3h 5m | | 6 | 24h (capped) | ~27h 5m | | 7 | 24h (capped) | ~51h 5m | After the 7th attempt fails the event is moved to a dead-letter queue and is no longer retried automatically. Failures are visible via [Webhook logs](https://zernio.com/dashboard/webhooks) (`attemptNumber` records which try produced each log entry). Webhooks are never auto-disabled based on failure count, you can pause or remove them from your webhook settings. **Keep your handler fast.** Acknowledge the request as soon as you have persisted the event, then process it on a background worker. Long-running handlers risk hitting the 5-second timeout and triggering an unnecessary retry. ## Idempotency Webhook deliveries use **at-least-once** semantics: the same event may arrive more than once if a previous attempt's response was lost or your endpoint took too long to acknowledge. Your handler must therefore be idempotent. Every payload carries a stable event identifier that is also exposed as a header: - `payload.id`, the canonical event ID (UUID). - `X-Zernio-Event-Id`, the same value, repeated as a header for convenience. - `X-Late-Event-Id`, legacy alias of the above, kept for backward compatibility. Use this identifier as your deduplication key. A typical pattern is to insert the event ID into a unique-indexed table or cache before processing the payload, and skip processing when the insert conflicts. ## Signature verification If the webhook has a secret configured, every delivery includes an `X-Zernio-Signature` header. The signature is the lowercase hex `HMAC-SHA256` of the raw request body keyed by your webhook secret. - `X-Zernio-Signature`, the signature. - `X-Late-Signature`, legacy alias of the above, kept for backward compatibility. Read the raw body, compute the HMAC, and compare it to the header value: ```typescript import crypto from "crypto"; export const POST = async (req: Request) => { const webhookSignature = req.headers.get("X-Zernio-Signature"); if (!webhookSignature) { return new Response("No signature provided.", { status: 401 }); } const secret = process.env.ZERNIO_WEBHOOK_SECRET; if (!secret) { return new Response("No secret provided.", { status: 401 }); } const rawBody = await req.text(); const computedSignature = crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); if (webhookSignature !== computedSignature) { return new Response("Invalid signature", { status: 400 }); } const payload = JSON.parse(rawBody); // Handle the webhook event // ... }; ``` **Reject unsigned or mismatched requests.** A failed signature check means the request did not originate from Zernio (or the body was tampered with in transit). Do not process the payload. ## Available events | Event | Description | | --- | --- | | [`post.published`](#postpublished) | Fired when a post is successfully published. | | [`post.failed`](#postfailed) | Fired when a post fails to publish on all target platforms. | | [`post.partial`](#postpartial) | Fired when a post publishes on some platforms and fails on others. | | [`post.cancelled`](#postcancelled) | Fired when a post publishing job is cancelled. | | [`post.scheduled`](#postscheduled) | Fired when a post is scheduled for future publishing. | | [`post.recycled`](#postrecycled) | Fired when a post is recycled for republishing. | | [`post.external.created`](#postexternalcreated) | Fired when a post authored natively on the platform (outside Zernio) is detected for the first time. | | [`post.external.updated`](#postexternalupdated) | Fired when a tracked native post's text or media changes on the platform. | | [`post.external.deleted`](#postexternaldeleted) | Fired when a tracked native post is detected as removed from the platform. | | [`account.connected`](#accountconnected) | Fired when a social account is successfully connected. | | [`account.disconnected`](#accountdisconnected) | Fired when a connected social account becomes disconnected. | | [`account.ads.initial_sync_completed`](#accountadsinitial_sync_completed) | Fired once per ads-enabled account when the initial 90-day backfill completes. | | [`message.received`](#messagereceived) | Fired when a new inbox message is received. | | [`message.sent`](#messagesent) | Fired when an outgoing message is sent from the inbox. | | [`conversation.started`](#conversationstarted) | Fired once when a new conversation begins between an account and a contact, on any DM platform. | | [`message.edited`](#messageedited) | Fired when a sender edits a previously-sent message. | | [`message.deleted`](#messagedeleted) | Fired when a sender deletes (unsends) a message. | | [`message.delivered`](#messagedelivered) | Fired when an outgoing message is delivered to the recipient. | | [`message.read`](#messageread) | Fired when an outgoing message is read by the recipient. | | [`message.failed`](#messagefailed) | Fired when an outgoing message fails to deliver (WhatsApp only). | | [`reaction.received`](#reactionreceived) | Fired when a participant adds or removes an emoji reaction (WhatsApp, Telegram). | | [`comment.received`](#commentreceived) | Fired when a new comment is received on a tracked post. | | [`review.new`](#reviewnew) | Fired when a new review is posted on a connected account. | | [`review.updated`](#reviewupdated) | Fired when a review is edited or a reply is added. | | [`lead.received`](#leadreceived) | Fired when a new lead is submitted against a Meta Lead Gen form. | | [`ad.status_changed`](#adstatus_changed) | Fired when an ad, ad set, or campaign changes status on the ad platform (Meta only). | | [`call.received`](#callreceived) | Fired when an inbound WhatsApp call reaches one of your numbers and is routed to its destination. | | [`call.ended`](#callended) | Fired when a WhatsApp call ends; carries duration, end reason, cost breakdown, and the recording when enabled. | | [`call.failed`](#callfailed) | Fired when a WhatsApp call fails with a hard error before or during bridging. | | [`call.permission_request`](#callpermission_request) | Fired when a WhatsApp user accepts or rejects your call-permission request. | | [`whatsapp.number.activated`](#whatsappnumberactivated) | Fired when a WhatsApp number you provisioned finishes setup and is ready to connect. | | [`whatsapp.number.declined`](#whatsappnumberdeclined) | Fired when a regulated number order is declined in review and no number is activated. | | [`whatsapp.number.action_required`](#whatsappnumberaction_required) | Fired when the regulator asks for more information on a placed number order; the order stays pending until you provide it. | | [`whatsapp.number.verification_required`](#whatsappnumberverification_required) | Fired when a regulated number needs end-user ID verification; carries the link to forward. | | [`whatsapp.number.suspended`](#whatsappnumbersuspended) | Fired when an active number is suspended (e.g. failed payment); carries a `reason`. | | [`whatsapp.number.reactivated`](#whatsappnumberreactivated) | Fired when a suspended number is usable again. | | [`whatsapp.number.released`](#whatsappnumberreleased) | Fired when a number is released and no longer usable (terminal); carries a `reason`. | | [`webhook.test`](#webhooktest) | Fired when sending a test webhook to verify the endpoint configuration. | --- ## `post.published` Fired when a post is successfully published. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.published`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.failed` Fired when a post fails to publish on all target platforms. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.failed`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.partial` Fired when a post publishes on some platforms and fails on others. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.partial`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.cancelled` Fired when a post publishing job is cancelled. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.cancelled`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.scheduled` Fired when a post is scheduled for future publishing. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.scheduled`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.recycled` Fired when a post is recycled for republishing. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.recycled`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled - **post** (required) `object`: - **id** (required) `string`: No description - **content** (required) `string`: No description - **status** (required) `string`: No description - **scheduledFor** (required) `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **platforms** (required) `array[object]`: - **platform** (required) `string`: No description - **status** (required) `string`: No description - **platformPostId** `string`: No description - **publishedUrl** `string`: No description - **error** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.external.created` Fired when Zernio's background sync detects a post that was authored **natively on the platform** (outside Zernio), such as a Google Business Profile post created in the Google interface. This is **poll-driven** (roughly hourly), not real-time, because most platforms offer no push notification for merchant-authored posts. The payload's `post.source` is always `"external"`, and `post.id` is the platform-native post ID. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings). On a freshly connected account, every existing native post is reported as `post.external.created` on the first sync (a one-time backfill). Treat `created` as an idempotent upsert keyed on `post.id`.
**Payload for `post.external.created`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.external.created, post.external.updated, post.external.deleted - **post** (required): `ExternalPostWebhookPost` - See schema definition - **account** (required) `object`: - **id** (required) `string`: No description - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.external.updated` Fired when a tracked native post's text or media changes on the platform. Edits are detected by comparing the post's text and media structure and, where the platform exposes one, the platform's own edit timestamp. A media-URL-only refresh (some platforms rotate expiring CDN URLs) does **not** fire this event. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.external.updated`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.external.created, post.external.updated, post.external.deleted - **post** (required): `ExternalPostWebhookPost` - See schema definition - **account** (required) `object`: - **id** (required) `string`: No description - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `post.external.deleted` Fired when a tracked native post is detected as removed from the platform. The payload's `post.deletedAt` carries the detection time. Coverage is bounded to the most recent posts the platform's listing returns, so deletions of very old posts may not be detected. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `post.external.deleted`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: post.external.created, post.external.updated, post.external.deleted - **post** (required): `ExternalPostWebhookPost` - See schema definition - **account** (required) `object`: - **id** (required) `string`: No description - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `account.connected` Fired when a social account is successfully connected. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `account.connected`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: account.connected - **account** (required) `object`: - **accountId** (required) `string`: The account's unique identifier (same as used in /v1/accounts/{accountId}) - **profileId** (required) `string`: The profile's unique identifier this account belongs to - **platform** (required) `string`: No description - **username** (required) `string`: No description - **displayName** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `account.disconnected` Fired when a connected social account becomes disconnected. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `account.disconnected`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: account.disconnected - **account** (required) `object`: - **accountId** (required) `string`: The account's unique identifier (same as used in /v1/accounts/{accountId}) - **profileId** (required) `string`: The profile's unique identifier this account belongs to - **platform** (required) `string`: No description - **username** (required) `string`: No description - **displayName** `string`: No description - **disconnectionType** (required) `string`: Whether the disconnection was intentional (user action) or unintentional (token expired/revoked) - one of: intentional, unintentional - **reason** (required) `string`: Human-readable reason for the disconnection - **timestamp** (required) `string` (date-time): No description --- ## `account.ads.initial_sync_completed` Fired once per ads-enabled account when the initial sync completes. The initial sync runs after an ads-capable account is connected and performs ad-account discovery plus a 90-day historical ad backfill. The payload includes a sync summary reporting whether the backfill succeeded fully or partially and how many ads were synced vs. failed. When scoping was applied at connect time (see [Scoping sync to specific ad accounts](/guides/connecting-accounts#scoping-sync-to-specific-ad-accounts)), `account.platformAdAccountId` echoes the chosen ad account back (when scope is exactly one) and `account.platformAdAccountIds` lists every `act_*` actually synced. On failure (`sync.status == "failure"`), the payload also carries optional fields to help branch your UX without parsing prose: - `sync.error`: raw platform error message (truncated to ~2KB). - `sync.errorCode` / `sync.errorSubcode`: platform-native error codes when parseable (e.g. Meta `190`, `10`). - `sync.errorCategory`: a stable enum, one of `token_invalid`, `permission_denied`, `no_ad_accounts`, `rate_limited`, `discovery_failed`, `unknown`. New values may be added; existing ones are stable. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `account.ads.initial_sync_completed`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: account.ads.initial_sync_completed - **account** (required) `object`: - **accountId** (required) `string`: The account's unique identifier (same as used in /v1/accounts/{accountId}) - **profileId** (required) `string`: The profile's unique identifier this account belongs to - **platform** (required) `string`: No description - **username** (required) `string`: No description - **displayName** `string`: No description - **platformUserId** `string`: The platform-side account/ad-account ID (e.g. Meta ad account ID). - **profilePicture** `string` (uri): URL of the account's profile picture, when available. - **platformAdAccountId** `string`: When the consumer scoped the connect call to a single ad account, this echoes that ID back so the webhook can be correlated to the originating connect request without consulting the consumer's DB. Meta uses the `act_*` shape. (example: "act_1330190928038136") - **platformAdAccountIds** `array[string]`: Every ad-account ID that the connected token could see at discovery time. Useful for "we synced ads from these accounts" UX without a follow-up API call. Empty array when the token had no ad-account visibility. - **sync** (required) `object`: Summary of the initial ads sync backfill results. - **status** (required) `string`: Overall outcome of the initial sync. - one of: success, failure - **totalAds** (required) `integer`: Total number of ads discovered for backfill. - **synced** (required) `integer`: Number of ads successfully synced. - **failed** (required) `integer`: Number of ads that failed to sync. - **error** `string`: Free-form error message from the platform (typically Meta's Marketing API). Truncated to ~2KB. Present when `status` is `failure` (and sometimes on `success` when discovery saw zero ad accounts). For UX branching prefer `errorCategory`; this field is for human display and debugging. - **errorCode** `string`: Platform-native error code if parsed (e.g. Meta `190`, `10`, `200`). - **errorSubcode** `string`: Platform-native error subcode if parsed. - **errorCategory** `string`: Stable category for UX branching. New values may be added; existing ones are stable. Mapping: - `token_invalid`: access token is expired or revoked. Reconnect. - `permission_denied`: token lacks required scope, or the user has no role on the Business Manager that owns the ad account. Reconnect with full permissions, or have an admin grant access. - `no_ad_accounts`: token is valid but sees zero ad accounts. The user needs to connect a Business Manager that owns ad accounts. - `rate_limited`: platform throttled us. Sync will retry automatically. - `discovery_failed`: any other platform-side failure. Inspect `error`. - `unknown`: classifier could not categorize the failure. - one of: token_invalid, permission_denied, no_ad_accounts, rate_limited, discovery_failed, unknown - **timestamp** (required) `string` (date-time): No description --- ## `message.received` Fired when a new inbox message is received. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.received`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: message.received - **message** (required) `object`: - **id** (required) `string`: Internal message ID - **conversationId** (required) `string`: Internal conversation ID - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, whatsapp - **platformMessageId** (required) `string`: Platform's message ID - **direction** (required) `string`: No description - one of: incoming, outgoing - **text** (required) `string`: Message text content - **attachments** (required) `array[object]`: - **type** (required) `string`: Attachment type (image, video, file, sticker, audio) - **url** (required) `string`: Attachment URL (may expire for Meta platforms) - **payload** `object`: Additional attachment metadata - **sender** (required) `object`: - **id** (required) `string`: Sender's platform identifier. For WhatsApp this is the phone number (without leading `+`) when available, otherwise the `businessScopedUserId`. - **contactId** `string`: Zernio CRM Contact id for this sender, when one exists (omitted for outgoing/business sender). - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **phoneNumber** `string`: 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** `string`: 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** `string`: WhatsApp only. Parent BSUID for businesses with linked business portfolios. Omitted for standalone portfolios. - **whatsappUsername** `string`: 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** `object`: Instagram profile data for the sender. Only present for Instagram conversations. - **isFollower** `boolean`: Whether the sender follows your Instagram business account - **isFollowing** `boolean`: Whether your Instagram business account follows the sender - **followerCount** `integer`: The sender's follower count on Instagram - **isVerified** `boolean`: Whether the sender is a verified Instagram user - **sentAt** (required) `string` (date-time): No description - **isRead** (required) `boolean`: No description - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **metadata** `object`: Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback) - **quickReplyPayload** `string`: Payload from a quick reply tap (Facebook/Instagram Messenger). - **postbackPayload** `string`: Payload from a postback button tap (Facebook/Instagram Messenger). - **postbackTitle** `string`: Title of the tapped postback button (Facebook/Instagram Messenger). - **callbackData** `string`: Callback data from an inline keyboard button tap (Telegram). - **interactiveType** `string`: 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). - one of: button_reply, list_reply, nfm_reply - **interactiveId** `string`: 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** `string`: 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** `string`: 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** `object`: 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. - **storyReply** `object`: Instagram only. Populated when an IG user replies to one of the account's stories (Meta `messaging_story_replies`). Mutually exclusive in practice with `isStoryMention`. - **storyId** (required) `string`: The Instagram story ID the user replied to. - **storyUrl** `string`: Meta CDN URL for the story media. Expires approximately 24 hours after the story posted; consumers must fetch promptly or treat 404s as expected. - **isStoryMention** `boolean`: Instagram only. True when the message was generated by an IG user mentioning the account in their own story (`story_mention` attachment type). Mutually exclusive in practice with `storyReply`. - **referral** `object`: Ad-click attribution forwarded verbatim from Meta. Populated only on the FIRST inbound message after the click; absent on subsequent messages of the same conversation. The populated subset identifies the source platform: - `ctwa_clid` and `source_*` fields: WhatsApp CTWA (Click-to-WhatsApp). Attribution window is 7 days from click. Forward to Meta Conversions API for Business Messaging replay. - `ad_id` and `ads_context_data`: Facebook Messenger CTM (Click-to-Message) or Instagram CTD (Click-to-Direct). Use `ad_id` to attribute the conversation to a specific ad. - **ctwa_clid** `string`: Meta's GCLID-equivalent click identifier. - **source_id** `string`: No description - **source_type** `string`: No description - **source_url** `string`: No description - **headline** `string`: No description - **body** `string`: No description - **media_type** `string`: No description - **image_url** `string`: No description - **video_url** `string`: No description - **thumbnail_url** `string`: No description - **ad_id** `string`: Facebook Messenger CTM / Instagram CTD only. The Meta ad ID the user clicked to start the conversation. - **ref** `string`: Optional `ref` parameter passed through from the Meta ad creative. Facebook Messenger CTM / Instagram CTD only. - **source** `string`: Meta-supplied source identifier (e.g. `ADS`). Facebook Messenger CTM / Instagram CTD only. - **type** `string`: Meta-supplied referral type (e.g. `OPEN_THREAD`). Facebook Messenger CTM / Instagram CTD only. - **ads_context_data** `object`: Snapshot of the ad's public context at click time. Facebook Messenger CTM / Instagram CTD only. - **ad_title** `string`: No description - **photo_url** `string`: No description - **video_url** `string`: No description - **post_id** `string`: No description - **product_id** `string`: No description - **flow_id** `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `message.sent` Fired when an outgoing message is sent from the inbox. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.sent`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: message.sent - **message** (required) `object`: - **id** (required) `string`: Internal message ID - **conversationId** (required) `string`: Internal conversation ID - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, whatsapp - **platformMessageId** (required) `string`: Platform's message ID - **direction** (required) `string`: No description - one of: incoming, outgoing - **text** (required) `string`: Message text content - **attachments** (required) `array[object]`: - **type** (required) `string`: Attachment type (image, video, file, sticker, audio) - **url** (required) `string`: Attachment URL (may expire for Meta platforms) - **payload** `object`: Additional attachment metadata - **sender** (required) `object`: - **id** (required) `string`: No description - **contactId** `string`: Zernio CRM Contact id for this sender, when one exists. - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **sentAt** (required) `string` (date-time): No description - **isRead** (required) `boolean`: No description - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `conversation.started` Fired once when a new conversation begins between one of your connected accounts and a contact, in either direction. Platform-agnostic — covers every DM platform (Instagram, Messenger/Facebook, Telegram, WhatsApp, Twitter, Reddit, Bluesky) with a single subscription. Naturally deduped: a given conversation only fires this event the very first time it appears. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `conversation.started`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: conversation.started - **conversation** (required) `object`: - **id** (required) `string`: Internal conversation ID - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, whatsapp, twitter, reddit, bluesky - **platformConversationId** (required) `string`: No description - **participantId** `string`: Contact's platform identifier (IGSID - **participantName** (required) `string`: No description - **participantUsername** `string`: Contact's handle when the platform exposes one - **participantPicture** `string`: No description - **status** (required) `string`: No description - one of: active, archived - **contactId** `string`: Zernio CRM Contact ID for the participant, when one exists. Resolved by joining `participantId` to the ContactChannel collection (same join used by message.*, reaction.received, and call.* webhooks). Best-effort: omitted when no channel matches or `participantId` is absent. Lets integrators seed the CRM straight from `conversation.started` without waiting for the first `message.*` event. - **account** (required): `InboxWebhookAccount` - See schema definition - **startedAt** (required) `string` (date-time): When the conversation document was created. - **timestamp** (required) `string` (date-time): No description --- ## `message.edited` Fired when the sender edits a previously-sent message. Supported on **Instagram**, **Facebook Messenger**, and **Telegram**. The payload carries the full `editHistory` (oldest prior version first) so you can reconstruct every version of the message. `message.text` is the latest version. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.edited`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: message.edited - **message** (required): `InboxWebhookMessage` - See schema definition - **editHistory** (required) `array[object]`: Prior versions of the message, oldest first. - **text** (required) `string`: No description - **attachments** (required) `array[object]`: - **type** `string`: No description - **url** `string`: No description - **payload** `object`: No description - **editedAt** (required) `string` (date-time): No description - **editCount** (required) `integer`: Total number of edits applied to this message. - **editedAt** (required) `string` (date-time): When the most recent edit happened. - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `message.deleted` Fired when the 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, useful for moderation, compliance, or archival. The Zernio dashboard UI does not show this content. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.deleted`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: message.deleted - **message** (required): `InboxWebhookMessage` - See schema definition - **deletedAt** (required) `string` (date-time): No description - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `message.delivered` Fired when an outgoing message is delivered to the recipient. Supported on **WhatsApp** and **Facebook Messenger**. Instagram doesn't emit a separate delivery event (only `message.read`). Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.delivered`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: message.delivered, message.read, message.failed - **message** (required): `InboxWebhookMessage` - See schema definition - **statusAt** (required) `string` (date-time): When the platform reported this status. - **error** `object`: Populated only on message.failed. - **code** `integer`: No description - **title** `string`: No description - **message** `string`: No description - **explanation** `string`: Plain-language translation of `code` (e.g. for 131026, that the recipient has likely opted out of marketing messages while utility templates are unaffected). Null for unmapped codes; fall back to title/message. - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `message.read` Fired when an outgoing message is read by the recipient. Supported on **WhatsApp**, **Facebook Messenger**, and **Instagram**. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.read`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: message.delivered, message.read, message.failed - **message** (required): `InboxWebhookMessage` - See schema definition - **statusAt** (required) `string` (date-time): When the platform reported this status. - **error** `object`: Populated only on message.failed. - **code** `integer`: No description - **title** `string`: No description - **message** `string`: No description - **explanation** `string`: Plain-language translation of `code` (e.g. for 131026, that the recipient has likely opted out of marketing messages while utility templates are unaffected). Null for unmapped codes; fall back to title/message. - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `message.failed` Fired when an outgoing message fails to deliver. Currently only emitted for **WhatsApp** (Messenger, Instagram, and Telegram don't expose per-message failure via webhook). The payload `error` object includes `code`, `title`, and `message` from the platform, useful for categorising failures (e.g. `131026` for "recipient phone not reachable"). Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `message.failed`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: message.delivered, message.read, message.failed - **message** (required): `InboxWebhookMessage` - See schema definition - **statusAt** (required) `string` (date-time): When the platform reported this status. - **error** `object`: Populated only on message.failed. - **code** `integer`: No description - **title** `string`: No description - **message** `string`: No description - **explanation** `string`: Plain-language translation of `code` (e.g. for 131026, that the recipient has likely opted out of marketing messages while utility templates are unaffected). Null for unmapped codes; fall back to title/message. - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `reaction.received` Fired when a participant adds or removes an emoji reaction on a message. Supported on **WhatsApp** and **Telegram** (Telegram requires the bot to be an administrator in the chat; reactions in private chats are never delivered to bots). Available on the [Usage plan](/pricing) (or AppSumo with the Inbox add-on). This is distinct from `message.received`: a reaction is not a message. Branch on it separately so a 👍 isn't treated as an inbound DM. - `reaction.action` is `added` or `removed`. - `reaction.emoji` is the emoji reacted with. On WhatsApp removals the platform does not report which emoji was removed, so this may be an empty string when `action` is `removed`. - `reaction.platformMessageId` is the platform-native id of the reacted-to message and is always present. `reaction.messageId` is the Zernio message id of that message when it can be resolved. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `reaction.received`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: reaction.received - **reaction** (required) `object`: - **emoji** (required) `string`: The emoji reacted with. May be an empty string when `action` is `removed` on WhatsApp (Meta does not report which emoji was removed). - **action** (required) `string`: No description - one of: added, removed - **messageId** `string`: Internal Zernio message ID of the reacted-to message, when resolvable from the platform ID. - **platformMessageId** (required) `string`: Platform-native ID of the reacted-to message (e.g. WhatsApp wamid). - **sender** (required) `object`: The participant who added or removed the reaction. - **id** (required) `string`: No description - **contactId** `string`: Zernio CRM Contact id for this sender, when one exists. - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **phoneNumber** `string`: WhatsApp only. Sender's phone number in E.164 format (with leading `+`), when available. - **reactedAt** (required) `string` (date-time): No description - **conversation** (required): `InboxWebhookConversation` - See schema definition - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `comment.received` Fired when a new comment is received on a tracked post. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings). The payload includes an optional `comment.ad` object when the comment was made on paid content. For Instagram this carries `ad.id` and `ad.title` directly from the Meta webhook. For Facebook it carries `ad.promotionStatus` (`"active"` for boosted organic posts, `"ineligible"` for dark post creatives). The field is absent for comments on organic posts that are not currently promoted, so clients can filter ad-driven comments with a simple `if (comment.ad) { ... }` check.
**Payload for `comment.received`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: comment.received - **comment** (required) `object`: - **id** (required) `string`: Platform comment ID - **postId** (required) `string`: Internal post ID (null for posts not published through Zernio) - **platformPostId** (required) `string`: Platform's post ID - **platform** (required) `string`: No description - one of: instagram, facebook, twitter, youtube, linkedin, bluesky, reddit - **text** (required) `string`: Comment text content - **author** (required) `object`: - **id** (required) `string`: Author's platform ID - **username** `string`: No description - **name** `string`: No description - **picture** `string`: No description - **createdAt** (required) `string` (date-time): No description - **isReply** (required) `boolean`: Whether this is a reply to another comment - **parentCommentId** (required) `string`: Parent comment ID if this is a reply - **ad** `object`: 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. - **id** `string`: Meta ad ID (Instagram only). - **title** `string`: Ad creative title (Instagram only). - **promotionStatus** `string`: 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** (required) `object`: - **id** (required) `string`: Internal post ID (null for posts not published through Zernio) - **platformPostId** (required) `string`: Platform's post ID - **account** (required) `object`: - **id** (required) `string`: Social account ID - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `review.new` Fired when a new review is posted on a connected account. Currently supported for Google Business Profile (real-time via Pub/Sub). Available on the [Usage plan](/pricing) (or AppSumo with the Inbox add-on). Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `review.new`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: review.new - **review** (required): `ReviewWebhookReview` - See schema definition - **account** (required) `object`: - **id** (required) `string`: No description - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `review.updated` 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). Available on the [Usage plan](/pricing) (or AppSumo with the Inbox add-on). The payload has the same shape as `review.new`; when a reply is present, `review.hasReply` is `true` and `review.reply` is populated. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `review.updated`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: review.updated - **review** (required): `ReviewWebhookReview` - See schema definition - **account** (required) `object`: - **id** (required) `string`: No description - **platform** (required) `string`: No description - **username** (required) `string`: No description - **timestamp** (required) `string` (date-time): No description --- ## `lead.received` Fired when a new lead is submitted against a Meta Lead Gen (Instant) Form and ingested in real time via the Page `leadgen` webhook. Requires the Ads add-on. `lead.fields` is the flattened question-key to answer map (for multiple-choice questions the value is the option key, e.g. `k1`, not the display label). `lead.formId` / `lead.adId` / `lead.campaignId` give provenance; `lead.adId` is null for organic or test leads. Use `lead.leadgenId` (Meta's lead ID) or the canonical event `id` as your dedup key. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `lead.received`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: lead.received - **lead** (required) `object`: - **id** (required) `string`: Zernio lead ID (AdLead document ID) - **leadgenId** (required) `string`: Meta lead ID (the platform's leadgen_id) - **formId** (required) `string`: Lead Gen form ID the lead was submitted against - **formName** `string`: Human-readable form name (best-effort; may be null) - **adId** `string`: Meta ad ID that drove the lead (null for organic/test leads) - **adsetId** `string`: No description - **campaignId** `string`: No description - **fields** (required) `object`: Flattened question key -> answer map. For multiple-choice questions the value is the option key (e.g. "k1"), not the display label. - **isOrganic** (required) `boolean`: True when the lead came from an organic post rather than a paid ad - **createdAt** (required) `string` (date-time): Meta's lead creation time (ISO 8601) - **account** (required) `object`: - **id** (required) `string`: Social account ID (the facebook account owning the Page) - **platform** (required) `string`: No description - one of: facebook - **timestamp** (required) `string` (date-time): No description --- ## `ad.status_changed` Fired when a campaign, ad set, or ad on a connected ad platform changes status. Currently emitted only for **Meta** (`metaads`). The event is sourced from two Meta `ad_account` webhook fields: - `in_process_ad_objects` — the ad object finished processing and exited the `IN_PROCESS` state. `status.raw` carries Meta's `status_name` (e.g. `ACTIVE`, `PAUSED`, `PENDING_REVIEW`, `ARCHIVED`, `DELETED`, `DISAPPROVED`). - `with_issues_ad_objects` — the ad object entered the `WITH_ISSUES` state. `status.raw` is set to `WITH_ISSUES` and the `error` block is populated from Meta's `error_code` / `error_summary` / `error_message`. `adObject.level` is one of `CAMPAIGN`, `AD_SET`, or `AD`; creative-level events are not forwarded. Branch on `status.raw` to handle each transition, and use `error.code` (when present) as the stable discriminator — `error.summary` and `error.message` are localized to the ad-account owner's Meta locale. The `error` block is optional. It's present on most `WITH_ISSUES` events but can be absent (Meta does not always include diagnostics), and is never present on any other status. Always null-check `error` before reading `error.code`. **Fan-out:** matching is keyed on `adObject.platformAdAccountId`. When multiple connected Zernio `metaads` accounts are linked to the same Meta ad account, each receives its own delivery. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `ad.status_changed`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: ad.status_changed - **account** (required) `object`: The connected ad-platform account that owns the ad object. - **accountId** (required) `string`: Internal Zernio account ID (same as used in /v1/accounts/{accountId}). - **profileId** (required) `string`: Internal Zernio profile ID this account belongs to. - **platform** (required) `string`: Ad platform identifier. Currently always `metaads`. (example: "metaads") - **username** (required) `string`: Display username of the connected ad-platform account. - **displayName** `string`: Human-readable display name of the account, when available. - **adObject** (required) `object`: The ad-platform object the status change applies to. - **level** (required) `string`: Hierarchy level the status applies to. Mirrors Meta's `level`. Creative-level events are not forwarded. - one of: CAMPAIGN, AD_SET, AD - **platformId** (required) `string`: Platform-native ID of the campaign / ad set / ad. For Meta this is the bare numeric ID (e.g. `120244894077860689`). (example: "120244894077860689") - **platformAdAccountId** (required) `string`: Platform-native ad-account ID. For Meta this uses the `act_` shape. (example: "act_2129800524463520") - **status** (required) `object`: Status info. Branch on `status.raw` to handle each transition. - **raw** (required) `string`: Platform-native status string, forwarded verbatim. For Meta this is `status_name` from `in_process_ad_objects` (e.g. `ACTIVE`, `PAUSED`, `PENDING_REVIEW`, `ARCHIVED`, `DELETED`, `DISAPPROVED`), or `WITH_ISSUES` when sourced from `with_issues_ad_objects`. Not constrained by an `enum` — Meta may add new values. (example: "ACTIVE") - **error** `object`: Optional. Present on most `WITH_ISSUES` events, carrying the platform's error diagnostics. May be absent on some `WITH_ISSUES` events (Meta does not always include diagnostics). Always absent for any other `status.raw` value. Always null-check before reading. - **code** (required) `string`: Platform-native error code, forwarded verbatim. For Meta this is `error_code` as a string. Use as the stable discriminator — `summary` and `message` are localized. (example: "2643001") - **summary** `string`: Short human-readable summary (Meta `error_summary`). Localized to the ad-account owner's Meta locale — display only, do not match on it. (example: "Ad Processing Error") - **message** `string`: Full human-readable error message (Meta `error_message`). Localized — display only. - **timestamp** (required) `string` (date-time): ISO-8601 timestamp the webhook was produced. --- ## `call.received` Fired when an inbound WhatsApp call reaches one of your numbers and is routed to its configured destination (an AI voice agent, SIP endpoint, or phone number). The payload carries the Zernio call id, caller and business numbers, the destination snapshot (`forwardTo`), and `contactId`/`conversationId` links into the inbox so you can message the caller, during the call or after, with the regular send APIs. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `call.received`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: call.received - **call** (required) `object`: - **id** `string`: Internal Zernio Call doc id - **metaCallId** `string`: Meta wacid.* call id when known - **accountId** `string`: No description - **phoneNumberId** `string`: Meta phone_number_id - **direction** `string`: No description - one of: inbound, outbound - **from** `string`: Consumer wa_id / E.164 - **to** `string`: Business number (E.164) - **forwardTo** `string`: Destination snapshot at routing time - **contactId** `string`: No description - **conversationId** `string`: No description - **startedAt** `string` (date-time): No description - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `call.ended` Fired when a WhatsApp call ends. Carries `durationSeconds`, the `endReason` (`hangup`, `no_answer`, `rejected`, `error`), a cost breakdown under `call.billing`, and, when recording is enabled on the number, a `recordingUrl` with its expiry. The classic automation here is a missed-call follow-up: on `endReason: "no_answer"`, text the caller back over the linked `conversationId`. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `call.ended`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: call.ended - **call** (required) `object`: - **id** `string`: No description - **metaCallId** `string`: No description - **accountId** `string`: No description - **phoneNumberId** `string`: No description - **direction** `string`: No description - one of: inbound, outbound - **from** `string`: No description - **to** `string`: No description - **startedAt** `string` (date-time): No description - **endedAt** `string` (date-time): No description - **durationSeconds** `integer`: No description - **endReason** `string`: No description - one of: hangup, no_answer, rejected, error - **recordingUrl** `string`: No description - **recordingExpiresAt** `string` (date-time): No description - **billing** `object`: - **metaCostUSD** `number`: Meta per-minute charge. Billed by Meta DIRECTLY to your WhatsApp Business Account payment method (your separate Meta invoice). Zernio does NOT charge this. Display only. - **telnyxCostUSD** `number`: No description - **recordingCostUSD** `number`: No description - **billableCostUSD** `number`: The amount Zernio bills you = Telnyx leg + recording. Excludes Meta (billed by Meta directly). - **totalCostUSD** `number`: Full economic cost incl. the Meta portion you pay directly (Meta + Telnyx + recording). Display only, not the Zernio-billed amount. - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `call.failed` Fired when a WhatsApp call fails with a hard error before or during bridging (for example, the destination rejected the SIP INVITE). Carries the platform error `code` and `message`. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `call.failed`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: call.failed - **call** (required) `object`: - **id** `string`: No description - **metaCallId** `string`: No description - **accountId** `string`: No description - **phoneNumberId** `string`: No description - **direction** `string`: No description - one of: inbound, outbound - **from** `string`: No description - **to** `string`: No description - **failedAt** `string` (date-time): No description - **error** `object`: - **code** `integer`: No description - **message** `string`: No description - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `call.permission_request` Fired when a WhatsApp user responds to your call-permission request (business-initiated calls require one). Carries the user's number, their `response` (`accept` or `reject`), whether it is permanent, and the expiry when it is not. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `call.permission_request`:** - **id** (required) `string`: No description - **event** (required) `string`: No description - one of: call.permission_request - **permission** (required) `object`: - **from** `string`: Consumer wa_id who replied - **response** `string`: No description - one of: accept, reject - **isPermanent** `boolean`: No description - **expirationTimestamp** `string` (date-time): Present only when temporary - **responseSource** `string`: Meta's response source, typically `user_action` - **account** (required): `InboxWebhookAccount` - See schema definition - **timestamp** (required) `string` (date-time): No description --- ## `whatsapp.number.activated` Fired when a WhatsApp number you provisioned through Zernio finishes setup and is ready to connect. For regulated (non-US) numbers this can take 1-3 business days after the order is approved, so the webhook saves you from polling. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.activated`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.activated - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description --- ## `whatsapp.number.declined` Fired when a regulated number order is declined during regulatory review and no number is activated. The order never activates and you are never billed for it. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.declined`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.declined - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description - **reason** `string`: No description --- ## `whatsapp.number.action_required` Fired when the regulator reviewing an already-placed number order asks for more information (for example, a certificate of company incorporation). Nothing was rejected: the order stays pending, but it will not progress until the information is provided. `reason` carries the regulator's request verbatim when available. Provide the missing details from the dashboard's phone-numbers page, or re-submit the relevant fields via the remediation endpoint; the review resumes automatically. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.action_required`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.action_required - **timestamp** `string` (date-time): No description - **reason** `string`: No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description --- ## `whatsapp.number.verification_required` Fired when a regulated number requires the end user to complete an identity check (for example, Australian mobile numbers). The payload carries a one-time `verificationUrl` to forward to the person whose ID is on file; the order completes once they pass. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.verification_required`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.verification_required - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description - **verificationUrl** `string`: No description --- ## `whatsapp.number.suspended` Fired when an active number is suspended, for example after a failed payment. The number stops working until the issue is resolved, after which a `whatsapp.number.reactivated` event is sent. The payload carries a `reason` (such as `payment_failed` or `subscription_ended`). Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.suspended`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.suspended - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description - **reason** `string`: No description --- ## `whatsapp.number.reactivated` Fired when a suspended number is reactivated (for example, the payment recovered) and is usable again. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.reactivated`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.reactivated - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description --- ## `whatsapp.number.released` Fired when a number is released and is no longer usable, whether you released it, a billing cleanup released it, or an admin did. This is terminal. The payload carries a `reason` (such as `user_requested` or `cleanup_suspended`). Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `whatsapp.number.released`:** - **id** `string`: No description - **event** `string`: No description - one of: whatsapp.number.released - **timestamp** `string` (date-time): No description - **number** `object`: - **id** `string`: No description - **phoneNumber** `string`: No description - **country** `string`: No description - **profileId** `string`: No description - **reason** `string`: No description --- ## `webhook.test` Fired when sending a test webhook to verify the endpoint configuration. Subscribe with [Create webhook settings](/webhooks/create-webhook-settings) or [Update webhook settings](/webhooks/update-webhook-settings).
**Payload for `webhook.test`:** - **id** (required) `string`: Stable webhook event ID - **event** (required) `string`: No description - one of: webhook.test - **message** (required) `string`: Human-readable test message - **timestamp** (required) `string` (date-time): No description --- # Bluesky Schedule and automate Bluesky posts with Zernio API - Text posts, images, videos, threads, and App Password authentication import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 300 (HARD LIMIT) | | Images per post | 4 | | Videos per post | 1 | | Image formats | JPEG, PNG, WebP, GIF | | Image max size | 1 MB (auto-compressed, strict) | | Video format | MP4 only | | Video max size | 50 MB | | Video max duration | 60 seconds | | Post types | Text, Image, Video, Thread | | Scheduling | Yes | | Inbox (DMs) | Yes (text only) | | Inbox (Comments) | Yes | | Analytics | No | ## Before You Start Bluesky has a **HARD 300 character limit**. This is the **#1 cause of failed posts** -- 95% of all Bluesky failures are character limit exceeded. If you're cross-posting from ANY other platform (Twitter 280 is close but others are 500-63,000 chars), you **MUST** use `customContent` to provide a Bluesky-specific shorter version or your post **WILL** fail. Bluesky's image limit is **1 MB per image** -- much stricter than any other platform. Most phone photos are 3-5 MB. Zernio auto-compresses, but quality may degrade. Additional requirements: - Uses App Passwords, not OAuth (handle + app password from Bluesky Settings) - 300 char limit includes everything (text, URLs, mentions) - Each thread item is also limited to 300 characters - Images are strictly 1 MB per image ## Quick Start Post to Bluesky in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello from Zernio API!', platforms: [ { platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Bluesky!', post._id); ``` ```python result = client.posts.create_post( content="Hello from Zernio API!", platforms=[ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Bluesky! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from Zernio API!", "platforms": [ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text Post A simple text-only post. Keep it under 300 characters. ```typescript const { post } = await zernio.posts.createPost({ content: 'Just shipped a new feature!', platforms: [ { platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Bluesky!', post._id); ``` ```python result = client.posts.create_post( content="Just shipped a new feature!", platforms=[ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Bluesky! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Just shipped a new feature!", "platforms": [ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Image Post Attach up to 4 images per post. JPEG, PNG, WebP, and GIF formats are supported. Each image must be under 1 MB. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo.jpg' } ], platforms: [ { platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted with image!', post._id); ``` ```python result = client.posts.create_post( content="Check out this photo!", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], platforms=[ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted with image! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this photo!", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], "platforms": [ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` #### Multi-Image Post Attach up to 4 images. Remember: each image must be under 1 MB. ```typescript const { post } = await zernio.posts.createPost({ content: 'Product launch gallery', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo1.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo2.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo3.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo4.jpg' } ], platforms: [ { platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Multi-image post created!', post._id); ``` ```python result = client.posts.create_post( content="Product launch gallery", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo4.jpg"} ], platforms=[ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Multi-image post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Product launch gallery", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo4.jpg"} ], "platforms": [ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Video Post Attach a single video per post. MP4 format only, up to 50 MB, max 60 seconds. ```typescript const { post } = await zernio.posts.createPost({ content: 'New product demo', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/demo.mp4' } ], platforms: [ { platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Video post created!', post._id); ``` ```python result = client.posts.create_post( content="New product demo", media_items=[ {"type": "video", "url": "https://cdn.example.com/demo.mp4"} ], platforms=[ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Video post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New product demo", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/demo.mp4"} ], "platforms": [ {"platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Thread Create Bluesky threads with multiple connected posts using `platformSpecificData.threadItems`. Each item becomes a reply to the previous post and can have its own content and media. Each thread item is limited to 300 characters. > **Note:** 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]`. ```typescript const { post } = await zernio.posts.createPost({ platforms: [{ platform: 'bluesky', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { threadItems: [ { content: 'A thread about building APIs', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/api.jpg' }] }, { content: 'First, design your endpoints around resources, not actions.' }, { content: 'Second, always version your API from day one.' }, { content: 'Finally, document everything! Your future self will thank you.' } ] } }], publishNow: true }); console.log('Thread posted!', post._id); ``` ```python result = client.posts.create_post( platforms=[{ "platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "A thread about building APIs", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/api.jpg"}] }, {"content": "First, design your endpoints around resources, not actions."}, {"content": "Second, always version your API from day one."}, {"content": "Finally, document everything! Your future self will thank you."} ] } }], publish_now=True ) post = result.post print(f"Thread posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platforms": [{ "platform": "bluesky", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "A thread about building APIs", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/api.jpg"}] }, { "content": "First, design your endpoints around resources, not actions." }, { "content": "Second, always version your API from day one." }, { "content": "Finally, document everything! Your future self will thank you." } ] } }], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 4 per post | | **Formats** | JPEG, PNG, WebP, GIF | | **Max file size** | 1 MB per image (strict) | | **Max dimensions** | 2000 x 2000 px | | **Recommended** | 1200 x 675 px (16:9) | #### Aspect Ratios | Type | Ratio | Dimensions | |------|-------|------------| | Landscape | 16:9 | 1200 x 675 px | | Square | 1:1 | 1000 x 1000 px | | Portrait | 4:5 | 800 x 1000 px | ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per post | | **Format** | MP4 only | | **Max file size** | 50 MB | | **Max duration** | 60 seconds | | **Max dimensions** | 1920 x 1080 px | | **Frame rate** | 30 fps recommended | #### Recommended Video Specs | Property | Recommended | |----------|-------------| | Resolution | 1280 x 720 px (720p) | | Aspect ratio | 16:9 (landscape) or 1:1 (square) | | Frame rate | 30 fps | | Codec | H.264 | | Audio | AAC | ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Bluesky platform entry. | Field | Type | Description | |-------|------|-------------| | `threadItems` | Array\<\{content, mediaItems?\}\> | Complete sequence of posts in a Bluesky thread. The first item becomes the root post and must be provided as `threadItems[0]`. When `threadItems` is provided, top-level `content` is for display/search only and is NOT published. | ## Connection Bluesky uses **App Passwords** instead of OAuth. To connect a Bluesky account: 1. Go to your Bluesky Settings > App Passwords 2. Create a new App Password (formatted as `xxxx-xxxx-xxxx-xxxx`) 3. Use the connect endpoint with your handle and app password 4. Custom domain handles are supported (e.g., `brand.com` instead of `brand.bsky.social`) ```python account = client.connect.connect_bluesky_credentials( identifier="yourhandle.bsky.social", app_password="xxxx-xxxx-xxxx-xxxx", state="profile_id=YOUR_PROFILE_ID", ) print(f"Connected: {account['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/connect/bluesky/credentials \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "identifier": "yourhandle.bsky.social", "appPassword": "xxxx-xxxx-xxxx-xxxx", "state": "profile_id=YOUR_PROFILE_ID" }' ``` The Node SDK (`@zernio/node`) does not yet expose `connect.connectBlueskyCredentials`. Use the Python SDK or hit the endpoint directly via fetch. ## Rich Text Zernio auto-detects and converts text to AT Protocol facets. No special formatting is needed from developers: - `@handle.bsky.social` -- rendered as a clickable profile link - `#hashtag` -- rendered as a clickable hashtag - URLs -- rendered as clickable links with preview cards When your post contains a URL, Bluesky automatically generates a link card preview. For best results, place the URL at the end of your post and ensure the target page has proper Open Graph meta tags. ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, it will not work. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages - Hosted on a fast, reliable CDN **Supabase URLs:** Zernio auto-proxies Supabase storage URLs, so they work without additional configuration. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Likes | ✅ | | Comments | ✅ | | Shares (reposts) | ✅ | Bluesky does not provide impressions, reach, clicks, or view counts through its API. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'bluesky', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="bluesky", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=bluesky&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through Bluesky's API: - Create lists or starter packs - Create custom feeds - Pin posts to profile - Add content warnings or labels - Send DM attachments (Bluesky's Chat API does not support media) - See follower counts or profile analytics ## Common Errors Bluesky has a **19.3% failure rate** across Zernio's platform (3,633 failures out of 18,857 attempts). Here are the most frequent errors and how to fix them: | Error | What it means | How to fix | |-------|---------------|------------| | "Bluesky posts cannot exceed 300 characters" | Content exceeds the 300 char hard limit | Shorten to 300 chars. Use `customContent` for cross-platform posts. | | "Thread item N exceeds 300 characters" | A specific thread item is too long | Each thread item has its own 300 char limit. Split into more items. | | "Publishing failed due to max retries reached" | All retries failed | Usually temporary. Retry manually. | | App Password invalid | Wrong password type or expired credentials | Ensure you're using an App Password (`xxxx-xxxx-xxxx-xxxx`), not your main account password. Create a new one if needed. | | Image too large | Image exceeds 1 MB limit | Compress images before upload. Zernio auto-compresses, but may degrade quality. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Bluesky supports DMs and comments. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | ✅ | | Fetch messages | ✅ | | Send text messages | ✅ | | Send attachments | ❌ (API limitation) | | Archive/unarchive | ✅ | ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like comments | ✅ (requires CID) | | Unlike comments | ✅ (requires likeUri) | ### Limitations - **No DM attachments** - Bluesky's Chat API does not support media - **Like requires CID** - You must provide the content identifier (`cid`) when liking a comment - **Unlike requires likeUri** - Store the `likeUri` returned when liking to unlike later See [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) API Reference for endpoint details. ## Related Endpoints - [Connect Bluesky Account](/guides/connecting-accounts) - App Password authentication - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) --- # Discord Send messages, DMs, embeds, polls, forum posts, threads, schedule events, and manage roles in Discord servers with Zernio API - No bot hosting, no gateway, no intents approval import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Content limit | 2,000 characters (message content) | | Embeds per message | 10 (max 6,000 chars total) | | Images per message | Up to 10 attachments | | Image formats | JPEG, PNG, GIF, WebP | | Image max size | 25 MB (higher on boosted servers) | | Video formats | MP4, MOV, WebM | | Video max size | 25 MB (up to 500 MB on boosted servers) | | Post types | Messages, Embeds, Polls, Forum Posts, Threads | | Scheduling | Yes | | Direct Messages | Outbound only | | Role management | Yes (list / assign / remove) | | Member listing | Yes (cursor pagination) | | Pinned messages | Yes (list / pin / unpin) | | Scheduled Events | Yes (create / list / update / cancel / delete) | | Inbox (DM replies) | No (Discord requires a Gateway connection) | | Inbox (Comments) | No | | Analytics | No (Discord Bot API limit) | ## Before You Start Discord integration uses a **centralized bot token** model. You do NOT need to create a Discord Application, request privileged intents, or host a gateway connection. You add the Zernio bot to your server via OAuth and POST to our REST API. Key points: - **No bot hosting required** - we manage the bot infrastructure - **No intents approval** - MESSAGE_CONTENT and other privileged intents are handled at the bot level - **No gateway/sharding** - you never connect a WebSocket; everything is REST - The bot needs: Send Messages, Embed Links, Attach Files, Send Messages in Threads, Create Public Threads, Manage Messages (for pin/unpin), Manage Roles (for role assignment), and Manage Events (for scheduled events) permissions - all granted at install time on new connections. - **Already-connected servers**: existing Zernio installs predate the Manage Roles and Manage Events permissions. If those endpoints return 502, ask a server admin to either re-invite the bot (picks up new permissions automatically) or manually grant the missing permission on the bot's role in Server Settings → Roles. **Note on the Server Members Intent**: the `GET /v1/discord/guilds/{guildId}/members` endpoint requires the "Server Members Intent" enabled on the Zernio Discord app. This is on by default for new bot installs. If listing members returns an empty array with no error, this intent is the likely cause. ## Quick Start Send a message to a Discord channel: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello from Zernio API!', platforms: [ { platform: 'discord', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { channelId: '1234567890' } } ], publishNow: true }); console.log('Posted to Discord!', post._id); ``` ```python result = client.posts.create_post( content="Hello from Zernio API!", platforms=[ { "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890" } } ], publish_now=True, ) print(f"Posted to Discord! {result.post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from Zernio API!", "platforms": [ { "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890" } } ], "publishNow": true }' ``` ## Connection Discord uses OAuth2 with our centralized bot: 1. Call `GET /v1/connect/discord?profileId=YOUR_PROFILE_ID` to get the bot invite URL 2. The user authorizes the bot to join their server 3. After authorization, the user selects which channel to post to via `/connect/discord/select-channel` 4. The connected account appears in `GET /v1/accounts` Each connected account represents one Discord server. The `channelId` in `platformSpecificData` determines which channel receives the message. ## Webhook Identity (Bot Customization) By default, messages are posted as "Zernio" with the bot's avatar. You can customize the display name and avatar at two levels: **Account-level default** (applies to every post from this account): ```bash curl -X PATCH https://zernio.com/api/v1/connect/discord \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "webhookUsername": "My Brand", "webhookAvatarUrl": "https://example.com/logo.png" }' ``` **Per-post override** (overrides account default for a single post): ```json { "platforms": [{ "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890", "webhookUsername": "Special Announcement", "webhookAvatarUrl": "https://example.com/special-logo.png" } }], "publishNow": true } ``` Webhook usernames must be 1-80 characters and cannot contain "clyde" or "discord". Send an empty string to reset to the default ("Zernio"). ## Embeds Send rich embedded content alongside or instead of plain text: ```json { "content": "New release shipping today!", "platforms": [{ "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890", "embeds": [{ "title": "v2.3.0 Release Notes", "description": "Dark mode, new API endpoints, faster uploads.", "color": 5814783, "url": "https://example.com/changelog", "footer": { "text": "Shipped today" }, "fields": [ { "name": "New Features", "value": "3", "inline": true }, { "name": "Bug Fixes", "value": "12", "inline": true } ] }] } }], "publishNow": true } ``` Each message supports up to **10 embeds** with a combined character limit of 6,000 across all embed fields. ## Native Polls Create Discord-native polls (same UX as the Discord client): ```json { "platforms": [{ "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890", "poll": { "question": { "text": "Ship it now or wait until Monday?" }, "answers": [ { "poll_media": { "text": "Ship now" } }, { "poll_media": { "text": "Wait for Monday" } } ], "duration": 24, "allow_multiselect": false } } }], "publishNow": true } ``` | Poll Property | Value | |---------------|-------| | Max answers | 10 | | Duration range | 1 - 768 hours (32 days) | | Default duration | 24 hours | | Multi-select | Optional (default: false) | Polls are standalone messages. You cannot combine a poll with media attachments in the same message (Discord API limitation). ## Forum Posts Post starter messages to forum channels (type 15): ```json { "content": "Community call notes for January 15", "platforms": [{ "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "9999999999", "forumThreadName": "Community Call - Jan 15", "forumAppliedTags": ["11111111", "22222222"] } }], "publishNow": true } ``` - `forumThreadName` (required for forum channels) becomes the thread title - `forumAppliedTags` are snowflake IDs of existing forum tags (max 5 tags per post) - Use `listForumTags(channelId)` to discover available tags ## Threads Create a follow-up thread under any published message: ```json { "content": "Main announcement here", "platforms": [{ "platform": "discord", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "channelId": "1234567890", "threadFromMessage": { "name": "Discussion Thread", "autoArchiveDuration": 1440, "rateLimitPerUser": 0 } } }], "publishNow": true } ``` `autoArchiveDuration` options: 60, 1440 (1 day), 4320 (3 days), or 10080 (7 days) minutes. `rateLimitPerUser` sets slow-mode in seconds (0-21600). ## Announcement Crosspost Auto-crosspost messages from announcement channels (type 5) so followers in other servers receive them: ```json { "platformSpecificData": { "channelId": "ANNOUNCEMENT_CHANNEL_ID", "crosspost": true } } ``` This is a no-op for regular text channels. ## Edit & Delete - **Edit:** `PUT /v1/posts/{postId}` updates the Discord message content and embeds - **Delete:** `DELETE /v1/posts/{postId}` deletes the Discord message or cancels a scheduled one ## Rate Limits Discord applies per-channel, per-guild, and global rate limits. Zernio handles 429 responses with exponential backoff and respects the `X-RateLimit-Reset-After` header automatically. You never need to implement retry logic. ## platformSpecificData Reference | Field | Type | Required | Description | |-------|------|----------|-------------| | `channelId` | string | Yes | Target channel snowflake ID | | `embeds` | array | No | Up to 10 Discord embed objects (6,000 chars total) | | `poll` | object | No | Native poll (question, answers, duration). Cannot combine with media | | `crosspost` | boolean | No | Auto-crosspost in announcement channels | | `forumThreadName` | string | Forum channels | Thread title for forum starter message | | `forumAppliedTags` | string[] | No | Tag snowflake IDs for forum posts (max 5) | | `threadFromMessage` | object | No | Create a thread under the published message | | `threadFromMessage.rateLimitPerUser` | number | No | Slow-mode seconds for thread (0-21600) | | `tts` | boolean | No | Text-to-speech message | | `webhookUsername` | string | No | Override display name for this post (1-80 chars) | | `webhookAvatarUrl` | string | No | Override avatar URL for this post | ## Channel Management List available channels in the connected guild: ```typescript const { channels } = await zernio.discord.getDiscordChannels({ path: { accountId: 'YOUR_ACCOUNT_ID' }, }); ``` ```python res = client.discord.get_discord_channels(account_id="YOUR_ACCOUNT_ID") ``` ```bash curl https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/discord-channels \ -H "Authorization: Bearer YOUR_API_KEY" ``` Switch the connected channel: ```bash curl -X PATCH https://zernio.com/api/v1/connect/discord \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "channelId": "NEW_CHANNEL_ID" }' ``` Only text (0), announcement (5), and forum (15) channels are supported. A new webhook is automatically created in the target channel. ## Direct Messages Send a 1:1 message from the bot to a Discord user. Useful for operational messages outside the channel post flow — onboarding, billing reminders, password resets, support pings. ```bash curl -X POST https://zernio.com/api/v1/discord/dms \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "userId": "1234567890123456789", "content": "Welcome to Acme! Reply STOP to opt out." }' ``` Same payload shape as channel posts — also accepts `embeds`, `attachments`, and `tts`. At least one of `content`, `embeds`, or `attachments` is required. **Discord constraints to know about:** - The bot can only DM users it shares **at least one guild** with. - If the recipient has DMs disabled for non-friends, Discord returns 403 (surfaces as a 502 platform error). - Recipient must be identified by Discord **snowflake ID** (not username). No public API exists to resolve a username to a snowflake without the privileged `guild_members.read` scope. ## Role & Member Management Four endpoints for community-ops automation: list roles, list members (paginated), and assign / remove roles per member. Path shape mirrors Discord's own API so you can map mentally with zero translation. **List all roles in a guild** (id, name, color, position, permissions, flags): ```bash curl "https://zernio.com/api/v1/discord/guilds/GUILD_ID/roles?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **List members** (cursor-paginated, default 100/page, max 1,000): ```bash curl "https://zernio.com/api/v1/discord/guilds/GUILD_ID/members?accountId=YOUR_ACCOUNT_ID&limit=100" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Response: ```json { "data": [ { "user": { "id": "...", "username": "...", "global_name": "..." }, "nick": null, "roles": ["ROLE_ID_A", "ROLE_ID_B"], "joined_at": "2024-01-15T10:30:00.000Z" } ], "pagination": { "nextCursor": "USER_ID", "hasMore": true } } ``` Pass `&after=NEXT_CURSOR` on the next call until `hasMore: false`. **Assign a role to a member** (idempotent): ```bash curl -X PUT "https://zernio.com/api/v1/discord/guilds/GUILD_ID/members/USER_ID/roles/ROLE_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Remove a role from a member** (idempotent): ```bash curl -X DELETE "https://zernio.com/api/v1/discord/guilds/GUILD_ID/members/USER_ID/roles/ROLE_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Discord hierarchy rule**: the bot's highest role must be **above** the target role. If the bot is demoted in the guild's role hierarchy, it loses the ability to assign higher roles even with the Manage Roles permission. The `@everyone` role (where `roleId == guildId`) cannot be manipulated. ## Pin / Unpin Messages Pin messages in a channel for community-ops automation: announcement of the week, rules channel updates, auto-pin freshly published posts. **List pinned messages** (max 50 per channel, sorted most-recent first): ```bash curl "https://zernio.com/api/v1/discord/channels/CHANNEL_ID/pins?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Pin a message** (idempotent): ```bash curl -X PUT "https://zernio.com/api/v1/discord/channels/CHANNEL_ID/pins/MESSAGE_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Unpin a message** (idempotent): ```bash curl -X DELETE "https://zernio.com/api/v1/discord/channels/CHANNEL_ID/pins/MESSAGE_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Discord caps a channel at 50 pinned messages. Attempting to pin a 51st returns 400 — unpin one first. ## Scheduled Events Create Discord-native scheduled events — distinct from messages. They show up in the server's Events panel and Discord auto-notifies interested members ahead of start time. Perfect for AMAs, game tournaments, office hours, livestreams, web3 community events. Three event types via discriminated union on `entity.type`: **External event** (Zoom, IRL, off-platform livestream): ```bash curl -X POST https://zernio.com/api/v1/discord/guilds/GUILD_ID/events \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "name": "Weekly AMA", "description": "Bring your questions about the roadmap.", "startsAt": "2026-06-15T18:00:00Z", "entity": { "type": "external", "location": "https://zoom.us/j/123456789", "endsAt": "2026-06-15T19:00:00Z" } }' ``` For external events, both `location` and `endsAt` are required. **Voice channel event**: ```json { "accountId": "YOUR_ACCOUNT_ID", "name": "Game Night", "startsAt": "2026-06-20T20:00:00Z", "entity": { "type": "voice", "channelId": "VOICE_CHANNEL_ID" } } ``` **Stage channel event**: same shape as voice, with `"type": "stage"` and a stage channel id. **List events** (optionally with RSVP count): ```bash curl "https://zernio.com/api/v1/discord/guilds/GUILD_ID/events?accountId=YOUR_ACCOUNT_ID&withUserCount=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Get / Update / Delete one event**: ```bash # Fetch curl "https://zernio.com/api/v1/discord/guilds/GUILD_ID/events/EVENT_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" # Update (any subset of fields) curl -X PATCH "https://zernio.com/api/v1/discord/guilds/GUILD_ID/events/EVENT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "name": "Updated title" }' # Cancel (status transition - preserves the event in guild history) curl -X PATCH "https://zernio.com/api/v1/discord/guilds/GUILD_ID/events/EVENT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "status": "cancelled" }' # Hard delete (no history retained) curl -X DELETE "https://zernio.com/api/v1/discord/guilds/GUILD_ID/events/EVENT_ID?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Discord doesn't have a dedicated "cancel" endpoint — cancellation is a status transition via PATCH (`status: 'cancelled'`). Use PATCH if you want the event preserved in the guild's event history; use DELETE for a hard delete with no record. | Event Property | Value | |----------------|-------| | Name | Max 100 chars | | Description | Max 1,000 chars | | Privacy level | Always GUILD_ONLY (Discord deprecated PUBLIC events) | | External events | Require `location` (max 100 chars) + `endsAt` | | Voice/stage events | Require `channelId`; `endsAt` optional | | Cover image | Optional base64 data URI (PNG / JPEG / GIF) | | Start time | Must be in the future | ## Related Endpoints **Connection & posting** - [Connect Discord Account](/guides/connecting-accounts) - OAuth flow and bot installation - [Create Post](/posts/create-post) - Post creation and scheduling - [Discord Settings](/discord/update-discord-settings) - Update webhook identity and switch channels - [Discord Channels](/discord/get-discord-channels) - List guild channels **Direct Messages** - `POST /v1/discord/dms` - Send a 1:1 DM from the bot to a Discord user **Role & Member management** - `GET /v1/discord/guilds/{guildId}/roles` - List all roles in a guild - `GET /v1/discord/guilds/{guildId}/members` - Paginated member list - `PUT /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}` - Assign a role - `DELETE /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}` - Remove a role **Pinned messages** - `GET /v1/discord/channels/{channelId}/pins` - List pinned messages - `PUT /v1/discord/channels/{channelId}/pins/{messageId}` - Pin a message - `DELETE /v1/discord/channels/{channelId}/pins/{messageId}` - Unpin a message **Scheduled Events** - `POST /v1/discord/guilds/{guildId}/events` - Create an event (external / voice / stage) - `GET /v1/discord/guilds/{guildId}/events` - List events (optionally with RSVP count) - `GET /v1/discord/guilds/{guildId}/events/{eventId}` - Fetch one event - `PATCH /v1/discord/guilds/{guildId}/events/{eventId}` - Update or cancel (`status: 'cancelled'`) - `DELETE /v1/discord/guilds/{guildId}/events/{eventId}` - Hard delete --- # Facebook Schedule and automate Facebook Page posts with Zernio API - Feed posts, Stories, multi-image, multi-link carousels, GIFs, and first comments import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 63,206 (truncated at ~480 with "See more") | | Images per post | 10 | | Videos per post | 1 | | Image formats | JPEG, PNG, GIF (WebP auto-converted to JPEG) | | Image max size | 4 MB (Facebook rejects larger in practice) | | Video formats | MP4, MOV | | Video max size | 4 GB | | Video max duration | 240 min (feed), 120 sec (stories) | | Post types | Feed (text/image/video/multi-image/multi-link carousel), Story, Reel | | Scheduling | Yes | | Inbox (DMs) | Yes | | Inbox (Comments) | Yes | | Inbox (Reviews) | Yes | | Comment-to-DM automations | Yes | | Analytics | Yes | ## Before You Start Facebook API only posts to Pages, not personal profiles. You must have a Facebook Page and admin access to it. Also: Facebook often rejects photos larger than 4 MB even though the stated limit is higher. Keep images under 4 MB and use JPEG or PNG format. WebP images are auto-converted to JPEG by Zernio. - API posts to **Pages only** (not personal profiles) - User must be Page **Admin** or **Editor** - Facebook tokens expire frequently -- subscribe to the `account.disconnected` webhook - Multiple Pages can be managed from one connected account ## Quick Start Post to a Facebook Page in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello from Zernio API!', platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Facebook!', post._id); ``` ```python result = client.posts.create_post( content="Hello from Zernio API!", platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Facebook! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from Zernio API!", "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ## Draft Posts (Publishing Tools) Create an unpublished Facebook draft that appears in **Facebook Publishing Tools** instead of publishing immediately. This is useful for review/approval workflows. > **Note:** Drafts are supported for feed posts (text, link, image, video) and reels. Drafts are not supported for stories. `firstComment` is skipped when `draft: true`. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Draft post for review before publishing", "platforms": [{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID" }], "publishNow": true, "facebookSettings": { "draft": true } }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Draft post for review before publishing', platforms: [{ platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' }], publishNow: true, facebookSettings: { draft: true } }); console.log('Draft created!', post._id); ``` ```python result = client.posts.create_post( content="Draft post for review before publishing", platforms=[{"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"}], publish_now=True, facebook_settings={"draft": True} ) post = result.post print(f"Draft created! {post['_id']}") ``` ### Text-Only Post No media required. Facebook is one of the few platforms that supports text-only posts. ```typescript const { post } = await zernio.posts.createPost({ content: 'Just a text update for our followers.', platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Just a text update for our followers.", platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Just a text update for our followers.", "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Single Image Post ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { type: 'image', url: 'https://example.com/photo.jpg' } ], platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Check out this photo!", media_items=[ {"type": "image", "url": "https://example.com/photo.jpg"} ], platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this photo!", "mediaItems": [ {"type": "image", "url": "https://example.com/photo.jpg"} ], "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Multi-Image Post Facebook supports up to **10 images** in a single post. You cannot mix images and videos in the same post. ```typescript const { post } = await zernio.posts.createPost({ content: 'Photo dump from the weekend!', mediaItems: [ { type: 'image', url: 'https://example.com/photo1.jpg' }, { type: 'image', url: 'https://example.com/photo2.jpg' }, { type: 'image', url: 'https://example.com/photo3.jpg' } ], platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Photo dump from the weekend!", media_items=[ {"type": "image", "url": "https://example.com/photo1.jpg"}, {"type": "image", "url": "https://example.com/photo2.jpg"}, {"type": "image", "url": "https://example.com/photo3.jpg"} ], platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Photo dump from the weekend!", "mediaItems": [ {"type": "image", "url": "https://example.com/photo1.jpg"}, {"type": "image", "url": "https://example.com/photo2.jpg"}, {"type": "image", "url": "https://example.com/photo3.jpg"} ], "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Multi-Link Carousel Post Render a post as a 2-5 card carousel where each image has its own click-through link and headline. Useful for product catalogs, listings, or any post where each photo points to a different URL. Set `facebookSettings.carouselCards` to layer per-card metadata on top of your existing `mediaItems` (one card per image, in order). All items must be images -- video cards aren't supported by Facebook. Optionally set `facebookSettings.carouselLink` to control the "See more" destination shown on the carousel end card. > **Display truncation:** Facebook renders card titles around 35 characters and descriptions around 30 characters. Longer strings are accepted (up to 255 chars each in the API) but get truncated on render. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out our latest inventory', mediaItems: [ { type: 'image', url: 'https://example.com/car-1.jpg' }, { type: 'image', url: 'https://example.com/car-2.jpg' }, { type: 'image', url: 'https://example.com/car-3.jpg' } ], platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true, facebookSettings: { carouselLink: 'https://example.com/inventory', carouselCards: [ { link: 'https://example.com/inventory/car-1', name: '2024 Sedan', description: 'Low miles' }, { link: 'https://example.com/inventory/car-2', name: '2023 SUV', description: 'Certified pre-owned' }, { link: 'https://example.com/inventory/car-3', name: '2024 Truck', description: 'Loaded' } ] } }); ``` ```python result = client.posts.create_post( content="Check out our latest inventory", media_items=[ {"type": "image", "url": "https://example.com/car-1.jpg"}, {"type": "image", "url": "https://example.com/car-2.jpg"}, {"type": "image", "url": "https://example.com/car-3.jpg"} ], platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True, facebook_settings={ "carouselLink": "https://example.com/inventory", "carouselCards": [ {"link": "https://example.com/inventory/car-1", "name": "2024 Sedan", "description": "Low miles"}, {"link": "https://example.com/inventory/car-2", "name": "2023 SUV", "description": "Certified pre-owned"}, {"link": "https://example.com/inventory/car-3", "name": "2024 Truck", "description": "Loaded"} ] } ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out our latest inventory", "mediaItems": [ {"type": "image", "url": "https://example.com/car-1.jpg"}, {"type": "image", "url": "https://example.com/car-2.jpg"}, {"type": "image", "url": "https://example.com/car-3.jpg"} ], "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true, "facebookSettings": { "carouselLink": "https://example.com/inventory", "carouselCards": [ {"link": "https://example.com/inventory/car-1", "name": "2024 Sedan", "description": "Low miles"}, {"link": "https://example.com/inventory/car-2", "name": "2023 SUV", "description": "Certified pre-owned"}, {"link": "https://example.com/inventory/car-3", "name": "2024 Truck", "description": "Loaded"} ] } }' ``` ### Video Post A single video per post. For GIFs, use `type: 'video'` -- they are treated as videos internally, auto-play, and loop. ```typescript const { post } = await zernio.posts.createPost({ content: 'Watch our latest video!', mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Watch our latest video!", media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Watch our latest video!", "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [ {"platform": "facebook", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Story (Image or Video) Stories are 24-hour ephemeral content. Media is required. Text captions are **not** displayed on Stories, and interactive stickers are not supported via the API. ```typescript const { post } = await zernio.posts.createPost({ mediaItems: [ { type: 'image', url: 'https://example.com/story.jpg' } ], platforms: [{ platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'story' } }], publishNow: true }); ``` ```python result = client.posts.create_post( media_items=[ {"type": "image", "url": "https://example.com/story.jpg"} ], platforms=[{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mediaItems": [ {"type": "image", "url": "https://example.com/story.jpg"} ], "platforms": [{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], "publishNow": true }' ``` ### Reel (Video) Publish a Facebook Reel (short vertical video). Reels require a **single vertical video**. > **Note:** `content` is used as the Reel caption. Use `platformSpecificData.title` to set a separate Reel title. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes 🎬", "mediaItems": [ {"type": "video", "url": "https://example.com/reel.mp4"} ], "platforms": [{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "reel", "title": "Studio day" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Behind the scenes 🎬', mediaItems: [ { type: 'video', url: 'https://example.com/reel.mp4' } ], platforms: [{ platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'reel', title: 'Studio day' } }], publishNow: true }); console.log('Posted Reel to Facebook!', post._id); ``` ```python result = client.posts.create_post( content="Behind the scenes 🎬", media_items=[ {"type": "video", "url": "https://example.com/reel.mp4"} ], platforms=[{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "reel", "title": "Studio day" } }], publish_now=True ) post = result.post print(f"Posted Reel to Facebook! {post['_id']}") ``` **Reel requirements** - Single video only (no images) - Vertical video recommended (9:16) - Duration: 3–60 seconds ### First Comment Auto-post a first comment immediately after your post is published. Does **not** work with Stories. > **Note:** `firstComment` is skipped when `draft: true`. ```typescript const { post } = await zernio.posts.createPost({ content: 'New product launch!', mediaItems: [ { type: 'image', url: 'https://example.com/product.jpg' } ], platforms: [{ platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { firstComment: 'Link to purchase: https://shop.example.com' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="New product launch!", media_items=[ {"type": "image", "url": "https://example.com/product.jpg"} ], platforms=[{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "firstComment": "Link to purchase: https://shop.example.com" } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New product launch!", "mediaItems": [ {"type": "image", "url": "https://example.com/product.jpg"} ], "platforms": [{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "firstComment": "Link to purchase: https://shop.example.com" } }], "publishNow": true }' ``` ### Geo-Restriction Restrict who can see your Facebook post by country. This is a hard visibility restriction: users outside the specified countries cannot see the post at all. Supported for feed posts, videos, and reels. Not supported for stories. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "This post is only visible in the US and Spain", "platforms": [{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "geoRestriction": { "countries": ["US", "ES"] } } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'This post is only visible in the US and Spain', platforms: [{ platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { geoRestriction: { countries: ['US', 'ES'] } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="This post is only visible in the US and Spain", platforms=[{ "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "geoRestriction": { "countries": ["US", "ES"] } } }], publish_now=True ) ``` `geoRestriction.countries` accepts up to 25 uppercase ISO 3166-1 alpha-2 country codes (e.g. `"US"`, `"GB"`, `"DE"`). ## Media Requirements ### Images | Property | Feed Post | Story | |----------|-----------|-------| | **Max images** | 10 | 1 | | **Formats** | JPEG, PNG, GIF (WebP auto-converted) | JPEG, PNG | | **Max file size** | 4 MB | 4 MB | | **Recommended** | 1200 x 630 px | 1080 x 1920 px | ### Videos | Property | Feed Video | Story | |----------|------------|-------| | **Max videos** | 1 | 1 | | **Formats** | MP4, MOV | MP4, MOV | | **Max file size** | 4 GB | 4 GB | | **Max duration** | 240 minutes | 120 seconds | | **Min duration** | 1 second | 1 second | | **Recommended resolution** | 1280 x 720 px min | 1080 x 1920 px | | **Frame rate** | 30 fps recommended | 30 fps | | **Codec** | H.264 | H.264 | ## Platform-Specific Fields All fields below go inside `platformSpecificData` for the Facebook platform entry. | Field | Type | Description | |-------|------|-------------| | `draft` | boolean | When true, creates an unpublished draft in Facebook Publishing Tools instead of publishing immediately. Not supported for Stories. | | `contentType` | `"story"` \| `"reel"` | Set to `"story"` for Page Stories (24h ephemeral) or `"reel"` for Reels (short vertical video). Defaults to feed post if omitted. | | `title` | string | Reel title (only for `contentType="reel"`). Separate from the `content` caption. | | `firstComment` | string | Auto-posted as the first comment after publish. Feed posts and Reels (not Stories). Skipped when `draft` is true. | | `pageId` | string | Post to a specific Page when the connected account manages multiple Pages. Get available pages with `GET /v1/accounts/{accountId}/facebook-page`. | | `carouselCards` | array | Multi-link carousel cards (2–5). Requires `mediaItems` with the same length (images only). Mutually exclusive with `contentType="story"` or `"reel"`. | | `carouselLink` | string | Optional top-level “See more” link for the carousel end card. Only used with `carouselCards`. | | `geoRestriction` | object | Restrict post visibility to specific countries. See [Geo-Restriction](#geo-restriction) below. | ## Multi-Page Posting If your connected Facebook account manages multiple Pages, you can list them, set a default, or override per post. ### List Available Pages ```typescript const pages = await zernio.connect.getFacebookPages('YOUR_ACCOUNT_ID'); console.log('Available pages:', pages); ``` ```python pages = client.connect.get_facebook_pages("YOUR_ACCOUNT_ID") print("Available pages:", pages) ``` ```bash curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/facebook-page \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Post to Multiple Pages Use the same `accountId` multiple times with different `pageId` values: ```typescript const { post } = await zernio.posts.createPost({ content: 'Exciting news from all our brands!', platforms: [ { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { pageId: '111111111' } }, { platform: 'facebook', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { pageId: '222222222' } } ], publishNow: true }); console.log('Posted to Facebook!', post._id); ``` ```python result = client.posts.create_post( content="Exciting news from all our brands!", platforms=[ { "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"pageId": "111111111"} }, { "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"pageId": "222222222"} } ], publish_now=True ) post = result.post print(f"Posted to Facebook! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Exciting news from all our brands!", "platforms": [ { "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "pageId": "111111111" } }, { "platform": "facebook", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "pageId": "222222222" } } ], "publishNow": true }' ``` You can also set a default page with `POST /v1/accounts/{accountId}/facebook-page` so you don't need to pass `pageId` on every request. ## Media URL Requirements - URLs must be **publicly accessible** via HTTPS - No redirects, no authentication - Cloud storage sharing links (Google Drive, Dropbox) may not work -- use direct download URLs - WebP images are auto-converted to JPEG before upload ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Impressions | ✅ | | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Clicks | ✅ | | Views | ✅ | Facebook also provides a dedicated [Page Insights API](/analytics/get-facebook-page-insights) for page-level aggregates (media views, post engagements, video metrics, follower count). Uses current (post-November 2025) Meta metric names; the legacy page_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated by Meta and are rejected by this endpoint. followers_gained and followers_lost are synthesized from Zernio's daily follower snapshotter. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'facebook', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="facebook", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=facebook&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do - Post to **personal profiles** (Pages only) - Create Events - Post to Groups (deprecated by Facebook) - Go Live (requires the separate Facebook Live API) - Add interactive story stickers - Target audiences by demographics (age, gender, interests) for organic posts (country-level geo-restriction IS supported, see [Geo-Restriction](#geo-restriction)) ## Common Errors | Error | Meaning | Fix | |-------|---------|-----| | "Photos should be smaller than 4MB and saved as JPG or PNG." | Image exceeds actual size limit or unsupported format | Reduce to under 4 MB. Use JPEG or PNG. | | "Missing or invalid image file" | Facebook couldn't process image -- corrupt, wrong format, or inaccessible URL | Verify URL in an incognito browser. Ensure JPEG/PNG under 4 MB. | | "Unable to fetch video file from URL." | Facebook's servers couldn't download the video | Use a direct, publicly accessible URL. Avoid cloud storage sharing links. | | "Facebook tokens expired. Please reconnect." | OAuth token expired | Reconnect the account. Facebook tokens have shorter lifespans. Subscribe to the `account.disconnected` webhook. | | "Confirm your identity before you can publish as this Page." | Facebook security check triggered | Log into Facebook, go to the Page, and complete identity verification. | | "Publishing failed due to max retries reached" | All 3 retry attempts failed | Usually temporary. Retry manually or wait and try again. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Facebook has the most complete inbox support across DMs, comments, and reviews. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | ✅ | | Fetch messages | ✅ | | Send text messages | ✅ | | Send attachments | ✅ (images, videos, audio, files) | | Quick replies | ✅ (up to 13, Meta quick_replies) | | Buttons | ✅ (up to 3, generic template) | | Carousels | ✅ (generic template, up to 10 elements) | | Message tags | ✅ (4 types) | | Archive/unarchive | ✅ | **Message tags:** Use `messagingType: "MESSAGE_TAG"` with one of: `CONFIRMED_EVENT_UPDATE`, `POST_PURCHASE_UPDATE`, `ACCOUNT_UPDATE`, or `HUMAN_AGENT` to send messages outside the 24-hour messaging window. ### Webhooks Messenger emits the full set of message lifecycle webhooks: | Event | When it fires | |-------|---------------| | `message.received` | New incoming DM | | `message.sent` | Outgoing DM is sent | | `message.edited` | The sender edits a previously-sent message (up to 5 edits per Meta) | | `message.delivered` | An outgoing DM is delivered to the recipient | | `message.read` | The recipient reads an outgoing DM | Messages are stored locally via webhooks. See the [Webhooks](/webhooks) page for payload details. **Note:** Messenger does not expose incoming-message unsend via webhook, so `message.deleted` is not emitted for Facebook. This is a platform limitation. ### Persistent Menu Manage the persistent menu shown in Facebook Messenger conversations. Max 3 top-level items, max 5 nested items. See [Account Settings](/account-settings/get-messenger-menu) for the `GET/PUT/DELETE /v1/accounts/{accountId}/messenger-menu` endpoints. ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like comments | ✅ | | Hide/unhide comments | ✅ | | Send private reply (DM after comment) | ✅ (text + up to 13 quick replies OR 1-3 inline buttons, 7-day window, one per comment) | ### Reviews (Pages) | Feature | Supported | |---------|-----------| | List reviews | ✅ | | Reply to reviews | ✅ | ## Related Endpoints - [Connect Facebook Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Post performance metrics - [Messages](/messages/list-inbox-conversations), [Comments](/comments/list-inbox-comments), and [Reviews](/reviews/list-inbox-reviews) - [Account Settings](/account-settings/get-messenger-menu) - Persistent menu configuration --- # Google Ads Create Search and Display campaigns via Zernio API - No MCC or Standard Access application required import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; **Included with the [Usage plan](/pricing).** No MCC or Standard Access application needed. Zernio operates under our own approved developer token. **Display campaigns need two images.** Google's Responsive Display Ads (RDA) require BOTH a landscape (1.91:1, e.g. 1200x628) AND a square (1:1, e.g. 1080x1080) marketing image. Sending only one is rejected upstream as "Too few." (`NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET` or `NOT_ENOUGH_MARKETING_IMAGE_ASSET`). Pass both via `images: { landscape, square }`. ## What's Supported | Feature | Status | |---------|--------| | Search campaigns (Responsive Search Ads) | Yes | | Display campaigns (Responsive Display Ads) | Yes | | Campaign > Ad Group > Ad hierarchy | Yes | | Keyword targeting (Broad, Phrase, Exact) | Yes | | Geo + language targeting | Yes | | Real-time analytics (spend, CPC, CPM) | Yes | | Conversions API (offline/enhanced) via Data Manager | Yes | | Campaign URL tracking tags — read + update ([details](#url-tracking-tags)) | Yes | | Performance Max | Roadmap | | Shopping, Video, Demand Gen | Roadmap | | Customer Match audiences (create + member upload) | Yes | ## Create a Search Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_googleads_123", adAccountId: "987-654-3210", name: "US DevOps Search", campaignType: "search", goal: "traffic", budgetAmount: 50, budgetType: "daily", headline: "Internal Developer Platforms", body: "Spec-driven platform engineering.", linkUrl: "https://example.com/platform", keywords: ["platform engineering tools", "internal developer platform"], additionalHeadlines: ["Build your Dev Platform", "Ship DevEx Faster", "Internal Platforms, Done Right"], additionalDescriptions: ["Spec-driven platform. Free tier available.", "Enterprise features. Startup price."], countries: ["US", "CA", "GB"], }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_googleads_123", ad_account_id="987-654-3210", name="US DevOps Search", campaign_type="search", goal="traffic", budget_amount=50, budget_type="daily", headline="Internal Developer Platforms", body="Spec-driven platform engineering.", link_url="https://example.com/platform", keywords=["platform engineering tools", "internal developer platform"], additional_headlines=["Build your Dev Platform", "Ship DevEx Faster", "Internal Platforms, Done Right"], additional_descriptions=["Spec-driven platform. Free tier available.", "Enterprise features. Startup price."], countries=["US", "CA", "GB"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_googleads_123", "adAccountId": "987-654-3210", "name": "US DevOps Search", "campaignType": "search", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "Internal Developer Platforms", "body": "Spec-driven platform engineering.", "linkUrl": "https://example.com/platform", "keywords": ["platform engineering tools", "internal developer platform"], "additionalHeadlines": ["Build your Dev Platform", "Ship DevEx Faster", "Internal Platforms, Done Right"], "additionalDescriptions": ["Spec-driven platform. Free tier available.", "Enterprise features. Startup price."], "countries": ["US", "CA", "GB"] }' ``` **Valid `goal` values for Google Ads:** `engagement`, `traffic`, `awareness`, `video_views`. Conversion-goal campaigns aren't yet supported on `/v1/ads/create`, pass `traffic` for click-optimised delivery. `campaignType` is lowercase (`search` or `display`). `keywords` is a flat array of strings; match types default to broad. ## Create a Display Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_googleads_123", adAccountId: "987-654-3210", name: "Retargeting Display", campaignType: "display", goal: "traffic", budgetAmount: 40, budgetType: "daily", headline: "Finish your signup", body: "Come back and upgrade today.", linkUrl: "https://example.com/upgrade", businessName: "Acme", images: { landscape: "https://cdn.example.com/retarget-1200x628.jpg", square: "https://cdn.example.com/retarget-1080x1080.jpg", }, additionalHeadlines: ["Finish your signup", "Get 30% off this week"], additionalDescriptions: ["Come back and upgrade today."], countries: ["US"], }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_googleads_123", ad_account_id="987-654-3210", name="Retargeting Display", campaign_type="display", goal="traffic", budget_amount=40, budget_type="daily", headline="Finish your signup", body="Come back and upgrade today.", link_url="https://example.com/upgrade", business_name="Acme", images={ "landscape": "https://cdn.example.com/retarget-1200x628.jpg", "square": "https://cdn.example.com/retarget-1080x1080.jpg", }, additional_headlines=["Finish your signup", "Get 30% off this week"], additional_descriptions=["Come back and upgrade today."], countries=["US"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_googleads_123", "adAccountId": "987-654-3210", "name": "Retargeting Display", "campaignType": "display", "goal": "traffic", "budgetAmount": 40, "budgetType": "daily", "headline": "Finish your signup", "body": "Come back and upgrade today.", "linkUrl": "https://example.com/upgrade", "businessName": "Acme", "images": { "landscape": "https://cdn.example.com/retarget-1200x628.jpg", "square": "https://cdn.example.com/retarget-1080x1080.jpg" }, "additionalHeadlines": ["Finish your signup", "Get 30% off this week"], "additionalDescriptions": ["Come back and upgrade today."], "countries": ["US"] }' ``` Display campaigns require `businessName` (max 25 chars) on top of the Search fields. `longHeadline` defaults to `headline` if not supplied (max 90 chars). Both `images.landscape` and `images.square` are required, Google rejects RDA with only one. ## Conversions API Import offline conversions (deal closed, lead qualified, subscription renewed) into Google Ads via the Data Manager API `ingestEvents` method, the endpoint Google recommends for all new integrations as of December 2025 (replacing the legacy `uploadClickConversions`). Zernio uses the Google Ads account you already connected; no additional OAuth, no developer token to apply for. The same endpoint handles both attribution paths: - **Click attribution**, include a `gclid`, `gbraid`, or `wbraid` from the originating ad click - **Enhanced Conversions for Leads**, include hashed email / phone when no click ID is available PII is SHA-256 hashed server-side. For `@gmail.com` / `@googlemail.com` addresses, we strip dots and `+suffix` from the local part before hashing per Google's spec so `john.doe+promo@gmail.com` matches `johndoe@gmail.com`. ### Discover available conversion actions ```typescript const { data } = await zernio.ads.listConversionDestinations({ path: { accountId: 'ACCOUNT_ID' }, }); ``` ```python data = client.ads.list_conversion_destinations(account_id="ACCOUNT_ID") ``` ```bash curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns every enabled conversion action across accessible customers. The `id` is the conversion action resource name (`customers/{customerId}/conversionActions/{id}`). The `type` field reflects the action's category (PURCHASE, LEAD, SIGN_UP, etc.), Google locks the event type to the conversion action, not the per-event `eventName`. ### Send a conversion event ```typescript const result = await zernio.ads.sendConversions({ body: { accountId: 'ACCOUNT_ID', destinationId: 'customers/1234567890/conversionActions/987654321', consent: { adUserData: 'GRANTED', adPersonalization: 'GRANTED' }, // required for EEA/UK events: [{ eventName: 'Purchase', eventTime: Math.floor(Date.now() / 1000), eventId: 'order_abc_123', // dedup, mapped to transactionId value: 129.99, currency: 'USD', user: { email: 'customer@example.com', phone: '+14155551234', clickIds: { gclid: 'EAIaIQobChMI...' }, }, }], }}); ``` ```python result = client.ads.send_conversions( account_id="ACCOUNT_ID", destination_id="customers/1234567890/conversionActions/987654321", consent={"adUserData": "GRANTED", "adPersonalization": "GRANTED"}, events=[{ "eventName": "Purchase", "eventTime": int(time.time()), "eventId": "order_abc_123", "value": 129.99, "currency": "USD", "user": { "email": "customer@example.com", "phone": "+14155551234", "clickIds": {"gclid": "EAIaIQobChMI..."}, }, }], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/conversions" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "ACCOUNT_ID", "destinationId": "customers/1234567890/conversionActions/987654321", "consent": { "adUserData": "GRANTED", "adPersonalization": "GRANTED" }, "events": [{ "eventName": "Purchase", "eventTime": 1744732800, "eventId": "order_abc_123", "value": 129.99, "currency": "USD", "user": { "email": "customer@example.com", "phone": "+14155551234", "clickIds": { "gclid": "EAIaIQobChMI..." } } }] }' ``` ### EEA / UK consent Google enforces strict consent signaling for European users under the February 2026 Data Manager restrictions. Pass `consent` at the request root with `adUserData` and `adPersonalization` set to `GRANTED` or `DENIED`. Omit the field for non-EEA requests where you don't have consent data; Google applies region defaults. ### Deduplication Pass a stable `eventId` on every event, Zernio maps it to Google's `transactionId` so repeated uploads of the same conversion are deduped. ### Batching + reporting Up to 2,000 events per request (Zernio chunks larger batches automatically). Conversions take up to 3 hours to appear in Google Ads reports. The response carries a `requestId` you can reference if conversions don't appear as expected. ## URL tracking tags Read or update a campaign's click-URL tracking: `trackingUrlTemplate` (the redirect/tracking template, must contain `{lpurl}`) and `finalUrlSuffix` (parse-only `key=value` params appended to the landing page, the part that survives parallel tracking). Operate at the campaign level via the unified ad endpoint, passing any ad in the campaign. Read the current values: ```typescript const { data } = await zernio.ads.getAdTrackingTags({ path: { adId: 'AD_ID' } }); // { platform: 'google', level: 'campaign', trackingUrlTemplate: '{lpurl}?utm_source=google', finalUrlSuffix: 'utm_medium=cpc' } ``` ```python data = client.ads.get_ad_tracking_tags(ad_id="AD_ID") ``` ```bash curl "https://zernio.com/api/v1/ads/AD_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Update either field (send only the ones you want to change; omit a field to leave it untouched, pass an empty string to clear it): ```typescript const { data } = await zernio.ads.updateAdTrackingTags({ path: { adId: 'AD_ID' }, body: { trackingUrlTemplate: '{lpurl}?utm_source=google&utm_campaign={campaignid}', finalUrlSuffix: 'utm_medium=cpc', }, }); ``` ```python data = client.ads.update_ad_tracking_tags( ad_id="AD_ID", tracking_url_template="{lpurl}?utm_source=google&utm_campaign={campaignid}", final_url_suffix="utm_medium=cpc", ) ``` ```bash curl -X PATCH "https://zernio.com/api/v1/ads/AD_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "trackingUrlTemplate": "{lpurl}?utm_source=google&utm_campaign={campaignid}", "finalUrlSuffix": "utm_medium=cpc" }' ``` These reads and writes go through the Google Ads API, which is rate-limited by access tier. A one-off audit is fine; auditing large numbers of campaigns can hit the daily operation cap on Basic access. If you plan high-volume tag auditing, let us know and we'll make sure the underlying account is on the higher tier. ## Customer Match audiences Target a CRM list via Customer Match. Create an empty audience, then upload members. `adAccountId` is the Google Ads customer ID (no dashes). Email only on Google (any `phone` is ignored); values are SHA256-hashed server-side before upload. ```typescript // 1. Create the (empty) Customer Match audience await zernio.adaudiences.createAdAudience({ body: { accountId: 'acc_googleads_123', adAccountId: '1234567890', // Google Ads customer ID type: 'customer_list', name: 'Newsletter subscribers', }, }); // 2. Upload members to the returned audience id await zernio.adaudiences.addUsersToAdAudience({ path: { audienceId: 'aud_abc123' }, // id from the create response body: { users: [{ email: 'jane@example.com' }, { email: 'sam@example.com' }] }, }); ``` ```python # 1. Create the (empty) Customer Match audience client.ad_audiences.create_ad_audience( account_id="acc_googleads_123", ad_account_id="1234567890", # Google Ads customer ID type="customer_list", name="Newsletter subscribers", ) # 2. Upload members to the returned audience id client.ad_audiences.add_users_to_ad_audience( audience_id="aud_abc123", # id from the create response users=[{"email": "jane@example.com"}, {"email": "sam@example.com"}], ) ``` ```bash # 1. Create curl -X POST "https://zernio.com/api/v1/ads/audiences" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "accountId": "acc_googleads_123", "adAccountId": "1234567890", "type": "customer_list", "name": "Newsletter subscribers" }' # 2. Upload members (use the audience id from the create response) curl -X POST "https://zernio.com/api/v1/ads/audiences/AUDIENCE_ID/users" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Responsive Search Ad | Text | 15 headlines / 4 descriptions | 30/90 chars | | Responsive Display (landscape) | JPEG, PNG | 5120 KB | 1.91:1, 1200x628 recommended. Required. | | Responsive Display (square) | JPEG, PNG | 5120 KB | 1:1, 1080x1080 recommended. Required. | | Keyword | Text | 80 chars | BROAD, PHRASE, EXACT | ## Version Migrations Google Ads API ships v22, v23, v24... twice a year with breaking changes. Zernio absorbs these at our layer, so your integration keeps working without code changes. --- # Overview Complete guide to all social media platforms supported by Zernio API import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Zernio supports 15 major social media platforms. Each platform page includes quick start examples, media requirements, and platform-specific features. ## Platform Quick Reference ## Getting Started ### 1. Connect an Account Each platform uses OAuth or platform-specific authentication. Start by connecting an account: ```typescript const { url } = await zernio.connect.getConnectUrl({ path: { platform: 'twitter' }, query: { profileId: 'YOUR_PROFILE_ID' }, }); // Redirect the user to `url` to start the OAuth flow. ``` ```python res = client.connect.get_connect_url( platform="twitter", profile_id="YOUR_PROFILE_ID", ) # Redirect the user to res["url"] to start the OAuth flow. ``` ```bash curl "https://zernio.com/api/v1/connect/{platform}?profileId=YOUR_PROFILE_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Replace `{platform}` with: `twitter`, `instagram`, `facebook`, `linkedin`, `tiktok`, `youtube`, `pinterest`, `reddit`, `bluesky`, `threads`, `googlebusiness`, `telegram`, `snapchat`, `whatsapp`, or `discord`. ### 2. Create a Post Once connected, create posts targeting specific platforms: ```typescript const post = await zernio.posts.createPost({ body: { content: 'Hello from Zernio API!', platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' }, ], publishNow: true, }, }); ``` ```python post = client.posts.create_post( content="Hello from Zernio API!", platforms=[ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"}, ], publish_now=True, ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from Zernio API!", "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### 3. Cross-Post to Multiple Platforms Post to multiple platforms simultaneously: ```typescript const post = await zernio.posts.createPost({ body: { content: 'Cross-posting to all platforms!', platforms: [ { platform: 'twitter', accountId: 'acc_twitter' }, { platform: 'linkedin', accountId: 'acc_linkedin' }, { platform: 'bluesky', accountId: 'acc_bluesky' }, ], publishNow: true, }, }); ``` ```python post = client.posts.create_post( content="Cross-posting to all platforms!", platforms=[ {"platform": "twitter", "accountId": "acc_twitter"}, {"platform": "linkedin", "accountId": "acc_linkedin"}, {"platform": "bluesky", "accountId": "acc_bluesky"}, ], publish_now=True, ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Cross-posting to all platforms!", "platforms": [ {"platform": "twitter", "accountId": "acc_twitter"}, {"platform": "linkedin", "accountId": "acc_linkedin"}, {"platform": "bluesky", "accountId": "acc_bluesky"} ], "publishNow": true }' ``` ## Platform-Specific Features Each platform has unique capabilities: - **Twitter/X** - Threads, polls, scheduled spaces - **Instagram** - Stories, Reels, Carousels, Collaborators - **Facebook** - Reels, Stories, Page posts - **LinkedIn** - Documents (PDFs), Company pages, Personal profiles - **TikTok** - Privacy settings, duet/stitch controls - **YouTube** - Shorts, playlists, visibility settings - **Pinterest** - Boards, Rich pins - **Reddit** - Subreddits, flairs, NSFW tags, native video uploads (with videogif and custom poster support) - **Bluesky** - Custom feeds, app passwords - **Threads** - Reply controls - **Google Business** - Location posts, offers, events, performance metrics, search keywords - **Telegram** - Channels, groups, silent messages, protected content - **Snapchat** - Stories, Saved Stories, Spotlight, Public Profiles - **WhatsApp** - Template messages, broadcasts, contacts, conversations, Flows (interactive forms) - **Discord** - Messages, embeds, native polls, forum posts, threads, crosspost ## Analytics KPIs Matrix Which metrics does the [Analytics API](/analytics/get-analytics) return for each platform? | Platform | Impressions | Reach | Likes | Comments | Shares | Saves | Clicks | Views | |----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | Instagram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | | Facebook | ✅ | - | ✅ | ✅ | ✅ | - | ✅ | ✅ | | Twitter/X | ✅ | - | ✅ | ✅ | ✅ | - | ✅ | ✅ | | LinkedIn | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | ✅\* | | TikTok | - | - | ✅ | ✅ | ✅ | - | - | ✅ | | YouTube | - | - | ✅ | ✅ | ✅\*\* | - | - | ✅ | | Threads | ✅ | - | ✅ | ✅ | ✅ | - | - | ✅ | | Bluesky | - | - | ✅ | ✅ | ✅ | - | - | - | | Reddit | - | - | ✅ | ✅ | - | - | - | - | | Pinterest | ✅ | - | - | - | - | ✅ | ✅ | - | | Snapchat | - | ✅ | - | - | ✅ | - | - | ✅ | | Telegram | - | - | - | - | - | - | - | - | | WhatsApp | - | - | - | - | - | - | - | - | | Google Business\*\*\* | - | - | - | - | - | - | - | - | \* LinkedIn views only for video posts \*\* YouTube shares only available via daily Analytics API, not basic Data API \*\*\* Google Business per-post analytics deprecated by Google with no replacement. Use the [Performance API](/analytics/get-google-business-performance) for location-level metrics (impressions, clicks, calls, directions, bookings). YouTube also supports [audience demographics](/analytics/get-youtube-demographics) (age, gender, country breakdowns) via a dedicated endpoint. Google Business also supports [daily performance metrics](/analytics/get-google-business-performance) (impressions, clicks, calls, directions, bookings) and [search keywords](/analytics/get-google-business-search-keywords) via dedicated endpoints. ## Inbox Feature Matrix > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). The Inbox API provides a unified interface for managing DMs, comments, and reviews across all platforms. ### DMs Support | Platform | List | Fetch | Send Text | Attachments | Quick Replies | Buttons | Edit | Archive | |----------|------|-------|-----------|-------------|---------------|---------|------|---------| | Facebook | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | | Instagram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | | Twitter/X | ✅ | ✅ | ✅ | ✅ | - | - | - | ❌ | | Bluesky | ✅ | ✅ | ✅ | ❌ | - | - | - | ✅ | | Reddit | ✅ | ✅ | ✅ | ❌ | - | - | - | ✅ | | Telegram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | WhatsApp | ✅ | ✅ | ✅ | ✅ | - | - | - | ✅ | ### Comments Support | Platform | List | Post | Reply | Delete | Like | Hide | |----------|------|------|-------|--------|------|------| | Facebook | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Instagram | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | | Twitter/X | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Bluesky | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | Threads | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | | Reddit | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | YouTube | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | LinkedIn | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | TikTok | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Reviews Support | Platform | List | Reply | Delete Reply | |----------|------|-------|--------------| | Facebook | ✅ | ✅ | ❌ | | Google Business | ✅ | ✅ | ✅ | ### Webhooks Base message + comment events: | Platform | `comment.received` | `message.received` | `message.sent` | |----------|:---:|:---:|:---:| | Instagram | ✅ | ✅ | ✅ | | Facebook | ✅ | ✅ | ✅ | | Twitter/X | ✅ | - | - | | YouTube | ✅ | - | - | | LinkedIn | ✅ | - | - | | Bluesky | ✅ | ✅ | ✅ | | Reddit | ✅ | ✅ | ✅ | | Telegram | - | ✅ | ✅ | | WhatsApp | - | ✅ | ✅ | Message lifecycle events (edits, unsends, delivery status): | Platform | `message.edited` | `message.deleted` | `message.delivered` | `message.read` | `message.failed` | |----------|:---:|:---:|:---:|:---:|:---:| | Instagram | ✅ | ✅ |, | ✅ |, | | Facebook | ✅ |, | ✅ | ✅ |, | | WhatsApp |, | ✅ (business-side) | ✅ | ✅ | ✅ | | Telegram | ✅ |, |, |, |, | | Twitter/X |, |, |, |, |, | | Bluesky |, |, |, |, |, | | Reddit |, |, |, |, |, | Dashes indicate the underlying platform API does not expose that event; the gap is a platform limitation, not a Zernio one. ### Account Settings | Platform | Feature | Endpoint | |----------|---------|----------| | Facebook | Persistent menu | `/v1/accounts/{accountId}/messenger-menu` | | Instagram | Ice breakers | `/v1/accounts/{accountId}/instagram-ice-breakers` | | Telegram | Bot commands | `/v1/accounts/{accountId}/telegram-commands` | See [Account Settings](/account-settings/get-messenger-menu) for full endpoint documentation. ### No Support | Platform | Status | Notes | |----------|--------|-------| | Pinterest | No API | No inbox features available | | Snapchat | No API | No inbox features available | | TikTok | Not supported | No inbox features available | ### Platform Limitations | Platform | Limitation | |----------|------------| | Instagram | Reply-only comments, no comment likes (deprecated 2018) | | Twitter/X | DMs require `dm.read` and `dm.write` scopes, no archive/unarchive, reply search cached (2-min TTL) | | Bluesky | No DM attachments, like requires CID | | Threads | No DMs, no comment likes, reply-only comments (no top-level), supports hide/unhide | | Reddit | No DM attachments | | Telegram | Bot-based, media limits (photos 10MB, videos 50MB) | | YouTube | No DMs, no comment likes | | LinkedIn | Org accounts only, no comment likes | | WhatsApp | Template messages required outside 24h window, no comment support | See [Messages](/messages/list-inbox-conversations), [Comments](/comments/list-inbox-comments), and [Reviews](/reviews/list-inbox-reviews) API Reference for full endpoint documentation. ## Ad Platforms **Included** — Ads (Meta, TikTok, LinkedIn, Pinterest, X, Google) is bundled with every paid account on the [Usage plan](/pricing). Zernio also supports paid advertising across 6 ad networks via the `/v1/ads` endpoints. Create standalone campaigns, boost organic posts, manage audiences, and pull analytics from one REST API. | Platform | Key | Create | Boost | Audiences | Analytics | |----------|-----|--------|-------|-----------|-----------| | [Meta Ads](/platforms/meta-ads) (Facebook + Instagram) | `metaads` | Yes | Yes | Custom + Lookalike | Yes | | [Google Ads](/platforms/google-ads) | `googleads` | Search + Display | No | No | Yes | | [LinkedIn Ads](/platforms/linkedin-ads) | `linkedinads` | Yes | Yes | Read-only | Yes | | [TikTok Ads](/platforms/tiktok-ads) | `tiktokads` | Yes | Spark Ads | Custom + Lookalike | Yes | | [Pinterest Ads](/platforms/pinterest-ads) | `pinterestads` | Yes | Yes | Basic | Yes | | [X Ads](/platforms/x-ads) | `xads` | Yes | Yes | No | Yes | ### Ad Hierarchy | Platform | Top Level | Middle | Bottom | |----------|-----------|--------|--------| | Meta Ads | Campaign | Ad Set | Ad | | Google Ads | Campaign | Ad Group | Ad | | LinkedIn Ads | Campaign Group | Campaign | Creative | | TikTok Ads | Campaign | Ad Group | Ad | | Pinterest Ads | Campaign | Ad Group | Pin | | X Ads | Campaign | Line Item | Promoted Tweet | See each platform's page for Quick Start code examples (Node.js, Python, curl) and full endpoint docs. ## API Reference - [Connect Account](/guides/connecting-accounts) - OAuth flow for all platforms - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Post performance metrics - [Ads](/ads/list-ads) - Ad management, boost, and analytics - [Messages](/messages/list-inbox-conversations), [Comments](/comments/list-inbox-comments), and [Reviews](/reviews/list-inbox-reviews) - [Account Settings](/account-settings/get-messenger-menu) - Platform-specific messaging settings --- # Instagram Schedule and automate Instagram posts with Zernio API - Feed, Stories, Reels, Carousels, collaborators, and user tags import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 2,200 (caption) | | Images per post | 1 (feed), 10 (carousel) | | Videos per post | 1 | | Image formats | JPEG, PNG | | Image max size | 8 MB (auto-compressed) | | Video formats | MP4, MOV | | Video max size | 300 MB (feed/reels), 100 MB (stories) | | Video max duration | 90 sec (reels), 60 min (feed), 60 sec (story) | | Post types | Feed, Carousel, Story, Reel | | Scheduling | Yes | | Inbox (DMs) | Yes | | Inbox (Comments) | Yes (reply-only) | | Comment-to-DM automations | Yes | | Story-reply automations | Yes | | Analytics | Yes | ## Before You Start Instagram **requires** a Business or Creator account. Personal accounts cannot post via API. Google Drive, Dropbox, OneDrive, and iCloud links **do not work** as media URLs. These services return HTML pages, not media files. Instagram's servers cannot fetch media from them. Use direct CDN URLs or upload via Zernio's [media endpoint](/guides/media-uploads). Additional requirements: - Media is required for all posts (no text-only) - 100 posts per 24-hour rolling window (all content types combined) - First 125 characters of caption are visible before the "more" fold ## Quick Start Post an image to Instagram in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo.jpg' } ], platforms: [ { platform: 'instagram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Instagram!', post._id); ``` ```python result = client.posts.create_post( content="Check out this photo!", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], platforms=[ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Instagram! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this photo!", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], "platforms": [ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Feed Post A single image or video in the main feed. Best aspect ratio is 4:5 (portrait), but 1:1 (square) and 1.91:1 (landscape) are also supported. No `contentType` field is needed -- feed is the default. ```typescript const { post } = await zernio.posts.createPost({ content: 'Beautiful sunset today #photography', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/sunset.jpg' } ], platforms: [ { platform: 'instagram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Feed post created!', post._id); ``` ```python result = client.posts.create_post( content="Beautiful sunset today #photography", media_items=[ {"type": "image", "url": "https://cdn.example.com/sunset.jpg"} ], platforms=[ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Feed post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Beautiful sunset today #photography", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/sunset.jpg"} ], "platforms": [ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Carousel Up to 10 mixed image/video items. All items should share the same aspect ratio -- the first item determines the ratio for the entire carousel. ```typescript const { post } = await zernio.posts.createPost({ content: 'Trip highlights from last weekend', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo1.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo2.jpg' }, { type: 'video', url: 'https://cdn.example.com/clip.mp4' }, { type: 'image', url: 'https://cdn.example.com/photo3.jpg' } ], platforms: [ { platform: 'instagram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Carousel posted!', post._id); ``` ```python result = client.posts.create_post( content="Trip highlights from last weekend", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "video", "url": "https://cdn.example.com/clip.mp4"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], platforms=[ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Carousel posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Trip highlights from last weekend", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "video", "url": "https://cdn.example.com/clip.mp4"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], "platforms": [ {"platform": "instagram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Story Set `contentType: "story"` to publish to Stories. Stories disappear after 24 hours, text captions are not displayed, and link stickers are not available via the API (this is a limitation of Instagram's Graph API, not Zernio). ```typescript const { post } = await zernio.posts.createPost({ mediaItems: [ { type: 'image', url: 'https://cdn.example.com/story.jpg' } ], platforms: [{ platform: 'instagram', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'story' } }], publishNow: true }); console.log('Story posted!', post._id); ``` ```python result = client.posts.create_post( media_items=[ {"type": "image", "url": "https://cdn.example.com/story.jpg"} ], platforms=[{ "platform": "instagram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], publish_now=True ) post = result.post print(f"Story posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/story.jpg"} ], "platforms": [{ "platform": "instagram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], "publishNow": true }' ``` ### Reel Set `contentType: "reels"` to publish a Reel, or let Zernio auto-detect it from vertical 9:16 video under 90 seconds. Reels must be vertical (9:16) and no longer than 90 seconds. ```typescript const { post } = await zernio.posts.createPost({ content: 'New tutorial is up!', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/reel.mp4' } ], platforms: [{ platform: 'instagram', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'reels', shareToFeed: true } }], publishNow: true }); console.log('Reel posted!', post._id); ``` ```python result = client.posts.create_post( content="New tutorial is up!", media_items=[ {"type": "video", "url": "https://cdn.example.com/reel.mp4"} ], platforms=[{ "platform": "instagram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "reels", "shareToFeed": True } }], publish_now=True ) post = result.post print(f"Reel posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New tutorial is up!", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/reel.mp4"} ], "platforms": [{ "platform": "instagram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "reels", "shareToFeed": true } }], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Feed Post | Story | Carousel | |----------|-----------|-------|----------| | **Max images** | 1 | 1 | 10 | | **Formats** | JPEG, PNG | JPEG, PNG | JPEG, PNG | | **Max file size** | 8 MB | 8 MB | 8 MB each | | **Recommended** | 1080 x 1350 px | 1080 x 1920 px | 1080 x 1080 px | #### Aspect Ratios | Orientation | Ratio | Dimensions | Notes | |-------------|-------|------------|-------| | Portrait | 4:5 | 1080 x 1350 px | Best engagement for feed posts | | Square | 1:1 | 1080 x 1080 px | Standard feed and carousel | | Landscape | 1.91:1 | 1080 x 566 px | Widest allowed for feed | | Vertical | 9:16 | 1080 x 1920 px | Stories and Reels only | Feed posts accept aspect ratios between 0.8 (4:5) and 1.91 (1.91:1). Images outside that range must be posted as Stories or Reels. ### Videos | Property | Feed | Reel | Story | |----------|------|------|-------| | **Formats** | MP4, MOV | MP4, MOV | MP4, MOV | | **Max file size** | 300 MB | 300 MB | 100 MB | | **Max duration** | 60 min | 90 sec | 60 sec | | **Min duration** | 3 sec | 3 sec | 3 sec | | **Aspect ratio** | 4:5 to 1.91:1 | 9:16 | 9:16 | | **Resolution** | 1080 px wide | 1080 x 1920 px | 1080 x 1920 px | | **Codec** | H.264 | H.264 | H.264 | | **Frame rate** | 30 fps | 30 fps | 30 fps | Oversized media is auto-compressed. Images above 8 MB, videos above 300 MB (feed/reels) or 100 MB (stories) are compressed automatically. Original files are preserved. ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Instagram platform entry. | Field | Type | Default | Description | |-------|------|---------|-------------| | `contentType` | `"story"` \| `"reels"` | (feed) | Omit for regular feed post. Set to `"story"` for Stories or `"reels"` for Reels. | | `shareToFeed` | boolean | `true` | Reel-specific. Set to `false` to show the Reel in the Reels tab only, not the main feed. | | `collaborators` | Array\ | -- | Up to 3 usernames. Must be public Business/Creator accounts. Does not work with Stories. | | `userTags` | Array\<\{username, x, y, mediaIndex?\}\> | -- | Tag users in images (not videos). Coordinates are 0.0 to 1.0. `mediaIndex` targets a specific carousel slide (0-based, defaults to 0). | | `trialParams` | \{graduationStrategy\} | -- | Trial Reels, shown only to non-followers. `graduationStrategy` is `"MANUAL"` or `"SS_PERFORMANCE"` (auto-graduate if it performs well). | | `thumbOffset` | number (ms) | `0` | Millisecond offset from video start to use as thumbnail. Ignored if `instagramThumbnail` is set. | | `instagramThumbnail` | string (URL) | -- | Custom thumbnail for Reels. JPEG or PNG, recommended 1080 x 1920 px. Takes priority over `thumbOffset`. | | `audioName` | string | -- | Custom audio name for Reels (replaces "Original Audio"). Can only be set at creation. | | `firstComment` | string | -- | Auto-posted as the first comment. Works with feed posts and carousels, not Stories. Useful for links since captions do not have clickable links. | ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, it will not work. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages - Hosted on a fast, reliable CDN **Supabase URLs:** Zernio auto-proxies Supabase storage URLs, so they work without additional configuration. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Impressions | ✅ | | Reach | ✅ | | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Saves | ✅ | | Views | ✅ | Instagram also provides dedicated analytics endpoints for deeper insights: - **[Account Insights](/analytics/get-instagram-account-insights)** -- Account-level reach, views, accounts engaged, total interactions, follows_and_unfollows, profile links taps. Business or Creator accounts only. Note: only reach supports metricType=time_series; all other metrics (including follows_and_unfollows) are total_value only (Instagram API limitation). - **[Follower History](/analytics/get-instagram-follower-history)** -- Daily running follower count time series plus followers_gained and followers_lost deltas. Served from Zernio's daily snapshotter since Instagram removed follower_count from its /insights endpoint in Graph API v22+ and never exposed a historical daily series. - **[Demographics](/analytics/get-instagram-demographics)** -- Audience breakdowns by age, city, country, or gender. Requires at least 100 followers. ### Stories Stories live for 24 hours. Zernio exposes two endpoints to read them while they're alive plus persists final-state metrics via Meta's `story_insights` webhook so insights remain queryable after the story expires. - **[List active stories](/instagram/list-instagram-stories)** -- `GET /v1/accounts/{accountId}/instagram/stories` returns the currently-active stories with `mediaType`, `permalink`, `mediaUrl`, `thumbnailUrl`, and `timestamp`. Live videos, reshared stories, and copyright-flagged media are excluded by Meta. `caption`, `likeCount`, and `commentsCount` do not apply to story media. - **[Story insights](/instagram/get-instagram-story-insights)** -- `GET /v1/accounts/{accountId}/instagram/stories/{storyId}/insights` returns `views`, `reach`, `replies`, `shares`, `profileVisits`, `follows`, `totalInteractions`, and the navigation breakdown (`tapsForward`, `tapsBack`, `exits`, `swipesForward`). The response includes a `source` field of `live` (story still active, fetched from Meta), `cached` (story expired but its `story_insights` webhook payload was captured), or `unavailable` (expired and no webhook payload was captured -- typical when the account connected after the story expired). Counts below 5 may be returned as 0 due to Meta's privacy floor on small audiences. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'instagram', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="instagram", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=instagram&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through Instagram's API: - Add music to Reels - Use story stickers (polls, questions, links, countdowns) - Add location tags - Go Live - Create Guides - Apply filters - Tag products - Post to personal accounts (Business or Creator only) - Create top-level comments (reply-only through the API) ## Common Errors Instagram has a **10.2% failure rate** across Zernio's platform (35,634 failures out of 348,438 attempts). Here are the most frequent errors and how to fix them: | Error | Meaning | Fix | |-------|---------|-----| | "Cannot process video from this URL. Instagram cannot fetch videos from Google Drive, Dropbox, or OneDrive." | A cloud storage sharing link was used instead of a direct media URL | Use a direct CDN URL. Test in an incognito window -- if you see a webpage, it will not work. | | "You have reached the maximum of 100 posts per day." | Instagram's hard 24-hour rolling limit | Reduce posting volume. This limit includes all content types (feed, stories, reels, carousels). | | "Instagram blocked your request." | Automation detection triggered | Reduce posting frequency, vary content. Wait before retrying. | | "Duplicate content detected." | Identical content was already published recently | Modify the caption or media before retrying. | | "Media fetch failed, retrying... (failed after 3 attempts)" | Zernio could not download media from the provided URL | Verify the URL is publicly accessible and returns actual media bytes, not an HTML page. | | "Instagram access token expired." | The OAuth token for this account has expired | Reconnect the account. Subscribe to the `account.disconnected` webhook to catch this proactively. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Instagram supports DMs and comments with some limitations. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | ✅ | | Fetch messages | ✅ | | Send text messages | ✅ | | Send attachments | ✅ (images, videos, audio via URL) | | Quick replies | ✅ (up to 13, Meta quick_replies) | | Buttons | ✅ (up to 3, generic template) | | Carousels | ✅ (generic template, up to 10 elements) | | Message tags | ✅ (`HUMAN_AGENT` only) | | Archive/unarchive | ✅ | **Attachment limits:** 8 MB images, 25 MB video/audio. Attachments are automatically uploaded to temp storage and sent as URLs. **Message tags:** Use `messageTag: "HUMAN_AGENT"` with `messagingType: "MESSAGE_TAG"` to send messages outside the 24-hour messaging window. #### Instagram Profile Data Instagram conversations include an optional `instagramProfile` object on participants and webhook senders, useful for routing and automation: | Field | Type | Description | |-------|------|-------------| | `isFollower` | boolean | Whether the participant follows your business account | | `isFollowing` | boolean | Whether your business account follows the participant | | `followerCount` | integer | The participant's follower count | | `isVerified` | boolean | Whether the participant is a verified Instagram user | | `fetchedAt` | datetime | When this data was last fetched (conversations only) | Available in: - `GET /v1/inbox/conversations` and `GET /v1/inbox/conversations/{id}` - on each participant - `message.received` webhook - on `message.sender` ### Ice Breakers Manage ice breaker prompts shown when users start a new Instagram DM conversation. Max 4 ice breakers, question max 80 characters. See [Account Settings](/account-settings/get-instagram-ice-breakers) for the `GET/PUT/DELETE /v1/accounts/{accountId}/instagram-ice-breakers` endpoints. ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Post new top-level comment | ❌ (reply-only) | | Reply to comments | ✅ | | Delete comments | ✅ | | Like comments | ❌ (deprecated since 2018) | | Hide/unhide comments | ✅ | | Send private reply (DM after comment) | ✅ (text + up to 13 quick replies OR 1-3 inline buttons, 7-day window, one per comment) | ### Webhooks Instagram emits the full set of message lifecycle webhooks: | Event | When it fires | |-------|---------------| | `message.received` | New incoming DM | | `message.sent` | Outgoing DM is sent | | `message.edited` | The sender edits a previously-sent message | | `message.deleted` | The sender unsends a message | | `message.read` | The customer reads an outgoing DM | Messages are stored locally via webhooks. See the [Webhooks](/webhooks) page for payload details. **`message.deleted` note:** the payload retains the original `text` and `attachments` so API consumers can access pre-delete content for moderation or compliance use cases. The Zernio dashboard UI hides that content. ### Limitations - **Reply-only comments** - Cannot post new top-level comments, only replies to existing comments - **No comment likes** - Liking comments was deprecated in 2018 See [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) API Reference for endpoint details. ## Related Endpoints - [Connect Instagram Account](/guides/connecting-accounts) - OAuth flow via Facebook Business - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Post performance metrics - [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) - Inbox API - [Account Settings](/account-settings/get-instagram-ice-breakers) - Ice breakers configuration --- # LinkedIn Ads Create and boost LinkedIn campaigns via Zernio API - No Marketing Developer Platform application required import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; **Included with the [Usage plan](/pricing).** No LinkedIn Marketing Developer Platform application needed. Zernio is an approved partner. ## What's Supported | Feature | Status | |---------|--------| | Boost Company Page posts | Yes | | Create Single Image / Single Video Ads from scratch (`/v1/ads/create`) | Yes | | Campaign Group > Campaign > Creative hierarchy | Yes | | Location + language targeting | Yes | | Matched Audiences (read + create list-upload segments) | Yes | | Real-time analytics (spend, CPC, CPM) | Yes | | Conversions API (CAPI) | Yes | | Conversion rule CRUD + campaign associations | Yes | | Conversion attribution metrics readback | Yes | | Campaign URL tracking tags (Dynamic UTM) — read + update ([details](#url-tracking-tags)) | Yes | | B2B targeting (job title, seniority, company size) | Roadmap | | Lead Gen Forms | Roadmap | | Other ad types from scratch (Carousel / Document / Text / Dynamic / Message) | Roadmap | ## Boost a Company Page Post ```typescript const ad = await zernio.ads.boostPost({ body: { postId: "POST_ID", accountId: "ACCOUNT_ID", adAccountId: "517258773", // numeric sponsored account ID (from /v1/ads/accounts) name: "Boost product launch", goal: "engagement", budget: { amount: 50, type: "daily" }, schedule: { startDate: "2026-04-20", endDate: "2026-04-27" }, }}); ``` ```python ad = client.ads.boost_post( post_id="POST_ID", account_id="ACCOUNT_ID", ad_account_id="517258773", # numeric sponsored account ID (from /v1/ads/accounts) name="Boost product launch", goal="engagement", budget={"amount": 50, "type": "daily"}, schedule={"startDate": "2026-04-20", "endDate": "2026-04-27"}, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/boost" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "postId": "POST_ID", "accountId": "ACCOUNT_ID", "adAccountId": "517258773", "name": "Boost product launch", "goal": "engagement", "budget": { "amount": 50, "type": "daily" }, "schedule": { "startDate": "2026-04-20", "endDate": "2026-04-27" } }' ``` ## Create a Single Image or Video Ad Build an ad from scratch, no existing post needed. Zernio creates a Direct Sponsored Content ("dark post") authored by your Company Page (it never appears on the Page's feed; it only runs as the ad), then wraps it in a campaign group, campaign, and creative. ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "ACCOUNT_ID", adAccountId: "517258773", // numeric sponsored account ID organizationId: "107655573", // Company Page that authors the ad (or "urn:li:organization:107655573") name: "Spring launch (single image)", goal: "traffic", // engagement | traffic | awareness budgetAmount: 50, budgetType: "daily", headline: "Schedule social posts in one API call", body: "The social media API built for developers.", // post commentary (intro text above the ad) imageUrl: "https://cdn.example.com/launch-1200x627.jpg", linkUrl: "https://zernio.com", // destination, required when goal is "traffic" callToAction: "LEARN_MORE", // defaults to LEARN_MORE when linkUrl is set countries: ["US"], }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="ACCOUNT_ID", ad_account_id="517258773", organization_id="107655573", name="Spring launch (single image)", goal="traffic", budget_amount=50, budget_type="daily", headline="Schedule social posts in one API call", body="The social media API built for developers.", image_url="https://cdn.example.com/launch-1200x627.jpg", link_url="https://zernio.com", call_to_action="LEARN_MORE", countries=["US"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "ACCOUNT_ID", "adAccountId": "517258773", "organizationId": "107655573", "name": "Spring launch (single image)", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "Schedule social posts in one API call", "body": "The social media API built for developers.", "imageUrl": "https://cdn.example.com/launch-1200x627.jpg", "linkUrl": "https://zernio.com", "callToAction": "LEARN_MORE", "countries": ["US"] }' ``` ### Video ads For a **single video ad**, swap `imageUrl` for `video: { url }` (the two are mutually exclusive). The clip is uploaded to LinkedIn under the Company Page, the campaign format becomes `SINGLE_VIDEO`, and the `video_views` goal becomes available (it requires a video). LinkedIn auto-generates the poster frame, so a thumbnail isn't required. The request blocks while LinkedIn transcodes the video (short clips take ~10-30s). ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "ACCOUNT_ID", adAccountId: "517258773", organizationId: "107655573", name: "Spring launch (video)", goal: "video_views", // engagement | traffic | awareness | video_views budgetAmount: 50, budgetType: "daily", headline: "See it in action", body: "The social media API built for developers.", video: { url: "https://cdn.example.com/launch.mp4" }, // MP4 H.264/AAC, 3s-30min, 75KB-500MB linkUrl: "https://zernio.com", // optional unless goal is "traffic" callToAction: "LEARN_MORE", countries: ["US"], }}); ``` **The ad runs as a Direct Sponsored Content post authored by a Company Page.** Pass that page via `organizationId` (a numeric organization ID or a full `urn:li:organization:N` URN). If you connected the page itself as a Zernio account, or your ad account is owned by an organization, it's inferred and `organizationId` is optional. The authenticated LinkedIn member must be an **Administrator** or **Direct Sponsored Content Poster** of that page, and the page must be associated with the ad account, or LinkedIn returns `403`. Supported `goal` values: `engagement`, `traffic`, `awareness`, `video_views` (`video_views` requires `video`). `traffic` requires `linkUrl`. `headline` is required; supply exactly one of `imageUrl` or `video`. `body` becomes the post's intro text; `longHeadline` (optional) becomes the secondary description line on image link ads. `callToAction` accepts `LEARN_MORE`, `SIGN_UP`, `DOWNLOAD`, `SUBSCRIBE`, `REGISTER`, `JOIN`, `ATTEND`, `REQUEST_DEMO`, `VIEW_QUOTE`, `APPLY`, `SEE_MORE`, `SHOP_NOW`, `BUY_NOW`. The image is uploaded to LinkedIn under the Company Page; recommended ratio **1.91:1** (e.g. 1200×627), JPEG/PNG/GIF. Carousel, document, text, lead-gen, and conversion-optimized LinkedIn ads aren't supported via `/v1/ads/create` yet; use `/v1/ads/boost` to promote an existing post of any type in the meantime. ## Budget Minimums LinkedIn enforces a **$10/day** minimum for any ad format and **$100** minimum lifetime budget for inactive campaigns. ## Matched Audiences Create a list-upload (contact list) DMP segment and upload members. `adAccountId` is the LinkedIn sponsored account ID. Email only (any `phone` is ignored); values are SHA256-hashed server-side. Two LinkedIn-specific notes: each upload is a **full replace** of the segment's members (not an append), and LinkedIn segments **cannot be deleted via the API** — remove them from Campaign Manager. ```typescript // 1. Create the (empty) list-upload segment await zernio.adaudiences.createAdAudience({ body: { accountId: 'acc_linkedinads_123', adAccountId: '517258773', // LinkedIn sponsored account ID type: 'customer_list', name: 'Webinar attendees', }, }); // 2. Upload the member list (full replace each call) await zernio.adaudiences.addUsersToAdAudience({ path: { audienceId: 'aud_abc123' }, // id from the create response body: { users: [{ email: 'jane@example.com' }, { email: 'sam@example.com' }] }, }); ``` ```python # 1. Create the (empty) list-upload segment client.ad_audiences.create_ad_audience( account_id="acc_linkedinads_123", ad_account_id="517258773", # LinkedIn sponsored account ID type="customer_list", name="Webinar attendees", ) # 2. Upload the member list (full replace each call) client.ad_audiences.add_users_to_ad_audience( audience_id="aud_abc123", # id from the create response users=[{"email": "jane@example.com"}, {"email": "sam@example.com"}], ) ``` ```bash # 1. Create curl -X POST "https://zernio.com/api/v1/ads/audiences" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "accountId": "acc_linkedinads_123", "adAccountId": "517258773", "type": "customer_list", "name": "Webinar attendees" }' # 2. Upload the member list (use the audience id from the create response) curl -X POST "https://zernio.com/api/v1/ads/audiences/AUDIENCE_ID/users" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Single Image | JPEG, PNG, GIF | 5 MB | 1200x627 recommended | | Video | MP4 | 200 MB | 3s-30min, 75 MB recommended | | Carousel | JPEG, PNG | 10 MB/card | 2-10 cards, 1080x1080 | ## Conversions API Stream offline conversion events (deal closed, lead qualified, trial converted, purchase) back to LinkedIn through the same unified `/v1/ads/conversions` endpoint you already use for Meta and Google. Zernio uses the LinkedIn account you already connected, no separate Conversions API access token to generate from Campaign Manager. PII is SHA-256 hashed server-side per LinkedIn's spec before anything leaves your server (`externalIds` are passed through as plaintext per LinkedIn's requirement). **Reconnect required for LinkedIn accounts connected before Zernio shipped Conversions API support.** LinkedIn does not silently upgrade existing OAuth grants with the new `rw_conversions` scope. Accounts that lack it return `403` with code `linkedin_reconnect_required`; pass the user back through the LinkedIn connect flow once and they're set. ### Discover available conversion rules ```typescript const { data } = await zernio.ads.listConversionDestinations({ path: { accountId: 'ACCOUNT_ID' }, }); ``` ```python data = client.ads.list_conversion_destinations(account_id="ACCOUNT_ID") ``` ```bash curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns every CONVERSIONS_API rule across every sponsored ad account the connected token can access. Each destination carries an `adAccountId` you'll pass back on subsequent CRUD calls. ### Create a new conversion rule LinkedIn's conversion rule binds an event `type` (LEAD, PURCHASE, ADD_TO_CART, etc.) to the destination. By default the new rule is auto-associated with every campaign in the ad account; pass `autoAssociationType: "NONE"` to opt out and manage associations explicitly via `/associations`. ```typescript const dest = await zernio.ads.createConversionDestination({ path: { accountId: 'ACCOUNT_ID' }, body: { adAccountId: '517258773', // numeric or "urn:li:sponsoredAccount:..." name: 'Trial activation', type: 'Lead', // unified name OR LinkedIn enum (e.g. "QUALIFIED_LEAD") attributionType: 'LAST_TOUCH_BY_CAMPAIGN', postClickAttributionWindowSize: 30, viewThroughAttributionWindowSize: 7, }, }); ``` ```python dest = client.ads.create_conversion_destination( account_id="ACCOUNT_ID", ad_account_id="517258773", name="Trial activation", type="Lead", attribution_type="LAST_TOUCH_BY_CAMPAIGN", post_click_attribution_window_size=30, view_through_attribution_window_size=7, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "517258773", "name": "Trial activation", "type": "Lead", "attributionType": "LAST_TOUCH_BY_CAMPAIGN", "postClickAttributionWindowSize": 30, "viewThroughAttributionWindowSize": 7 }' ``` ### Send a conversion event ```typescript const result = await zernio.ads.sendConversions({ body: { accountId: 'ACCOUNT_ID', destinationId: '25639412', // conversion rule id from listConversionDestinations events: [{ eventName: 'Lead', // informational only — rule type is bound to destination eventTime: Math.floor(Date.now() / 1000), eventId: 'order_abc_123', value: 42.50, currency: 'USD', user: { email: 'customer@example.com', firstName: 'Jane', lastName: 'Doe', country: 'US', clickIds: { // Optional: improves match rate. Capture from li_fat_id on landing-page URLs // after enabling enhanced conversion tracking on the LinkedIn Insight Tag. li_fat_id: 'AQH...', }, }, }], }}); ``` ```python result = client.ads.send_conversions( account_id="ACCOUNT_ID", destination_id="25639412", events=[{ "eventName": "Lead", "eventTime": int(time.time()), "eventId": "order_abc_123", "value": 42.50, "currency": "USD", "user": { "email": "customer@example.com", "firstName": "Jane", "lastName": "Doe", "country": "US", "clickIds": {"li_fat_id": "AQH..."}, }, }], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/conversions" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "ACCOUNT_ID", "destinationId": "25639412", "events": [{ "eventName": "Lead", "eventTime": 1744732800, "eventId": "order_abc_123", "value": 42.50, "currency": "USD", "user": { "email": "customer@example.com", "firstName": "Jane", "lastName": "Doe", "country": "US", "clickIds": { "li_fat_id": "AQH..." } } }] }' ``` ### Standard event names `Purchase`, `Lead`, `CompleteRegistration`, `AddToCart`, `InitiateCheckout`, `AddPaymentInfo`, `Subscribe`, `StartTrial`, `ViewContent`, `Search`, `Contact`, `SubmitApplication`, `Schedule`. The unified name is informational on LinkedIn — the rule's `type` (set at create time) is what determines the LinkedIn-side conversion category. ### Mapping unified names → LinkedIn `type` enum | Unified | LinkedIn | |---|---| | `Purchase` | `PURCHASE` | | `Lead` | `LEAD` | | `CompleteRegistration` | `COMPLETE_SIGNUP` | | `AddToCart` | `ADD_TO_CART` | | `InitiateCheckout` | `START_CHECKOUT` | | `AddPaymentInfo` | `ADD_BILLING_INFO` | | `Subscribe` | `SUBSCRIBE` | | `StartTrial` | `START_TRIAL` | | `ViewContent` | `VIEW_CONTENT` | | `Search` | `SEARCH` | | `Contact` | `CONTACT` | | `SubmitApplication` | `SUBMIT_APPLICATION` | | `Schedule` | `SCHEDULE` | You can also pass a LinkedIn enum value directly (e.g. `"QUALIFIED_LEAD"`, `"OUTBOUND_CLICK"`) for types not in the unified set. ### User identifiers LinkedIn requires at least one of: SHA-256-hashed email, a LinkedIn first-party click ID (`li_fat_id`), an Acxiom or Oracle MOAT identifier, OR `userInfo` containing both `firstName` and `lastName`. Send as many as you have, hashed identifiers materially improve match rate. ### Deduplication Pass a stable `eventId` on every event. If you also fire the LinkedIn Insight Tag with the same event ID, LinkedIn discards the Conversions API copy and counts only the Insight Tag event. Aligns with LinkedIn's documented dedup behavior. ### Campaign associations By default `createConversionDestination` auto-associates the new rule with every active, paused, and draft campaign in the ad account (`autoAssociationType: "ALL_CAMPAIGNS"`). Auto-association is one-shot at create time, campaigns added later need explicit association: ```typescript const result = await zernio.ads.addConversionAssociations({ path: { accountId: 'ACCOUNT_ID', destinationId: '25639412' }, body: { adAccountId: '517258773', campaignIds: ['337643194', '345396555'], }, }); ``` ```python result = client.ads.add_conversion_associations( account_id="ACCOUNT_ID", destination_id="25639412", ad_account_id="517258773", campaign_ids=["337643194", "345396555"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations/25639412/associations" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "517258773", "campaignIds": ["337643194", "345396555"] }' ``` Returns a per-campaign `succeeded` / `failed` map so you can retry only the rows that didn't take. ### Attribution metrics Read conversion attribution back from LinkedIn's adAnalytics endpoint, pivoted by date: ```typescript const { data } = await zernio.ads.getConversionMetrics({ path: { accountId: 'ACCOUNT_ID', destinationId: '25639412' }, query: { adAccountId: '517258773', startDate: '2026-04-01', endDate: '2026-04-30', granularity: 'DAILY', }, }); ``` ```python data = client.ads.get_conversion_metrics( account_id="ACCOUNT_ID", destination_id="25639412", ad_account_id="517258773", start_date="2026-04-01", end_date="2026-04-30", granularity="DAILY", ) ``` ```bash curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations/25639412/metrics?adAccountId=517258773&startDate=2026-04-01&endDate=2026-04-30&granularity=DAILY" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Returns `externalWebsiteConversions`, `externalWebsitePostClickConversions`, `externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`, `qualifiedLeads`, `costInLocalCurrency` per date bucket. LinkedIn's retention rules apply: `granularity=DAILY` covers the last ~6 months only; `MONTHLY`/`YEARLY` extends to 24 months; `granularity=ALL` with a range > 6 months auto-rounds to month boundaries. ### Soft-delete LinkedIn doesn't expose a hard-delete on conversion rules. `DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}?adAccountId=...` flips `enabled: false` (the same operation Campaign Manager's UI delete performs); the rule remains fetchable as `status: 'inactive'`. ### Limits - 5,000 events per `sendConversions` call (LinkedIn's `BATCH_CREATE` cap) - 600 requests/min and 300,000 requests/day per token (LinkedIn-side) - `conversionHappenedAt` must be within the last 90 days - 365-day attribution windows are only valid for `LEAD`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, `SUBMIT_APPLICATION` ## URL tracking tags LinkedIn's Dynamic UTM Tracking lives at the **campaign** level: a set of params LinkedIn appends to every creative's landing-page URL under that campaign. `dynamicValueParameters` map a key to a LinkedIn token resolved at serve time (`CAMPAIGN_ID`, `CAMPAIGN_NAME`, `CAMPAIGN_GROUP_ID`, `CAMPAIGN_GROUP_NAME`, `CREATIVE_ID`, `ACCOUNT_ID`, `ACCOUNT_NAME`); `customValueParameters` map a key to a static string. Operate on them through the unified ad endpoint, passing any ad in the campaign. Read the current params: ```typescript const { data } = await zernio.ads.getAdTrackingTags({ path: { adId: 'AD_ID' } }); // { platform: 'linkedin', level: 'campaign', // dynamicValueParameters: { utm_campaign: 'CAMPAIGN_NAME' }, // customValueParameters: { utm_source: 'linkedin' } } ``` ```python data = client.ads.get_ad_tracking_tags(ad_id="AD_ID") ``` ```bash curl "https://zernio.com/api/v1/ads/AD_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Set or update them (send `dynamicValueParameters` and/or `customValueParameters`): ```typescript const { data } = await zernio.ads.updateAdTrackingTags({ path: { adId: 'AD_ID' }, body: { dynamicValueParameters: { utm_campaign: 'CAMPAIGN_NAME', utm_content: 'CREATIVE_ID' }, customValueParameters: { utm_source: 'linkedin', utm_medium: 'paid_social' }, }, }); ``` ```python data = client.ads.update_ad_tracking_tags( ad_id="AD_ID", dynamic_value_parameters={"utm_campaign": "CAMPAIGN_NAME", "utm_content": "CREATIVE_ID"}, custom_value_parameters={"utm_source": "linkedin", "utm_medium": "paid_social"}, ) ``` ```bash curl -X PATCH "https://zernio.com/api/v1/ads/AD_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "dynamicValueParameters": { "utm_campaign": "CAMPAIGN_NAME", "utm_content": "CREATIVE_ID" }, "customValueParameters": { "utm_source": "linkedin", "utm_medium": "paid_social" } }' ``` Params set on a campaign apply to all its creatives (existing and new) without re-review. Conversation, Message, and Lead Gen Form ads don't support dynamic UTM params, and any static UTMs baked into a creative's own landing-page URL should not reuse the same keys, or they'll collide. --- # LinkedIn Schedule and automate LinkedIn posts with Zernio API - Personal profiles, company pages, images, videos, documents, and multi-organization posting import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 3,000 | | Images per post | 20 | | Videos per post | 1 | | Documents per post | 1 (PDF, PPT, PPTX, DOC, DOCX) | | Image formats | JPEG, PNG, GIF | | Image max size | 8 MB | | Video formats | MP4, MOV, AVI | | Video max size | 5 GB | | Video max duration | 10 min (personal), 30 min (company page) | | Post types | Text, Image, Multi-image, Video, Document | | Scheduling | Yes | | Inbox (Comments) | Yes (company pages only) | | Inbox (DMs) | No (LinkedIn blocks third-party DM access) | | Analytics | Yes | ## Before You Start LinkedIn **actively suppresses posts containing external links**. Your post's organic reach can drop 40-50% if you include a URL in the caption. Best practice: put your link in the first comment using the `firstComment` field. Also: LinkedIn rejects duplicate content with a **422 error**. You cannot post the same text twice, even across different time periods. Additional details: - Works with **personal profiles** AND **company pages** - Company pages: full analytics, 30-min video, comments API - Personal profiles: limited analytics, 10-min video - Cannot mix media types (images + videos or images + documents in the same post) - First ~210 characters of your post are visible before the "see more" fold ## Quick Start Post to LinkedIn in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Excited to share our latest update!\n\nWe have been working hard on this feature...', platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Excited to share our latest update!\n\nWe have been working hard on this feature...", platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Excited to share our latest update!\n\nWe have been working hard on this feature...", "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text-Only Post Text posts get the highest organic reach on LinkedIn. The first ~210 characters appear before the "see more" fold, so lead with your hook. Up to 3,000 characters total. ```typescript const { post } = await zernio.posts.createPost({ content: 'I spent 3 years building the wrong product.\n\nHere is what I learned about validating ideas before writing code...', platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="I spent 3 years building the wrong product.\n\nHere is what I learned about validating ideas before writing code...", platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "I spent 3 years building the wrong product.\n\nHere is what I learned about validating ideas before writing code...", "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Single Image Post Attach one image to a post. Recommended size is 1200 x 627 px (landscape). ```typescript const { post } = await zernio.posts.createPost({ content: 'Our new office setup is looking great!', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/office.jpg' } ], platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Our new office setup is looking great!", media_items=[ {"type": "image", "url": "https://cdn.example.com/office.jpg"} ], platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our new office setup is looking great!", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/office.jpg"} ], "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Multi-Image Post LinkedIn supports up to **20 images** in a single post. Cannot include videos or documents alongside images. ```typescript const { post } = await zernio.posts.createPost({ content: 'Highlights from our team retreat!', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo1.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo2.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo3.jpg' } ], platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Highlights from our team retreat!", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Highlights from our team retreat!", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Video Post One video per post. Personal profiles support up to 10 minutes, company pages up to 30 minutes. MP4, MOV, and AVI formats. ```typescript const { post } = await zernio.posts.createPost({ content: 'Watch our latest product demo', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/demo.mp4' } ], platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Watch our latest product demo", media_items=[ {"type": "video", "url": "https://cdn.example.com/demo.mp4"} ], platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Watch our latest product demo", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/demo.mp4"} ], "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Document/Carousel Post LinkedIn uniquely supports document uploads (PDF, PPT, PPTX, DOC, DOCX) that display as swipeable carousels. Max 100 MB, up to 300 pages. Cannot include images or videos alongside a document. ```typescript const { post } = await zernio.posts.createPost({ content: 'Download our 2024 Industry Report', mediaItems: [ { type: 'document', url: 'https://cdn.example.com/report.pdf' } ], platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Download our 2024 Industry Report", media_items=[ {"type": "document", "url": "https://cdn.example.com/report.pdf"} ], platforms=[ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Download our 2024 Industry Report", "mediaItems": [ {"type": "document", "url": "https://cdn.example.com/report.pdf"} ], "platforms": [ {"platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` **Document tips:** - First page is the cover/preview -- design it to grab attention - Design for mobile viewing (large fonts, minimal text per slide) - Ideal length for engagement: 10-15 pages - Password-protected PDFs will not work ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 20 per post | | **Formats** | JPEG, PNG, GIF | | **Max file size** | 8 MB per image | | **Recommended** | 1200 x 627 px | | **Min dimensions** | 552 x 276 px | | **Max dimensions** | 8192 x 8192 px | #### Aspect Ratios | Type | Ratio | Dimensions | Use Case | |------|-------|------------|----------| | Landscape | 1.91:1 | 1200 x 627 px | Link shares, standard | | Square | 1:1 | 1080 x 1080 px | Engagement | | Portrait | 1:1.25 | 1080 x 1350 px | Mobile feed | ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per post | | **Formats** | MP4, MOV, AVI | | **Max file size** | 5 GB | | **Max duration** | 10 min (personal), 30 min (company page) | | **Min duration** | 3 seconds | | **Resolution** | 256 x 144 px to 4096 x 2304 px | | **Aspect ratio** | 1:2.4 to 2.4:1 | | **Frame rate** | 10-60 fps | #### Recommended Video Specs | Property | Recommended | |----------|-------------| | Resolution | 1920 x 1080 px (1080p) | | Aspect ratio | 16:9 (landscape) or 1:1 (square) | | Frame rate | 30 fps | | Codec | H.264 | | Audio | AAC, 192 kbps | | Bitrate | 10-30 Mbps | ### Documents | Property | Requirement | |----------|-------------| | **Max documents** | 1 per post | | **Formats** | PDF, PPT, PPTX, DOC, DOCX | | **Max file size** | 100 MB | | **Max pages** | 300 pages | ## Platform-Specific Fields All fields go inside `platformSpecificData` on the LinkedIn platform entry. | Field | Type | Description | |-------|------|-------------| | `documentTitle` | string | 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` | string | Post to a specific LinkedIn company page. Format: `urn:li:organization:123456`. Get available orgs via `GET /v1/accounts/{accountId}/linkedin-organizations`. If omitted, posts to default org or personal profile. | | `firstComment` | string | Auto-posted as first comment after publish. Best practice: put external links here since LinkedIn suppresses link posts by 40-50%. | | `disableLinkPreview` | boolean | Set to `true` to suppress the automatic URL preview card. Default: `false`. | | `geoRestriction` | object | Restrict post visibility to specific countries. Organization pages only, requires 300+ targeted followers. See [Geo-Restriction](#geo-restriction) below. | ## Geo-Restriction Restrict who can see your LinkedIn post by country. This is a hard visibility restriction: only followers in the specified countries see the post. **Organization pages only** (not personal profiles). LinkedIn requires the targeted audience to exceed 300 followers or the post will be rejected. ```json { "platforms": [{ "platform": "linkedin", "accountId": "YOUR_ORG_ACCOUNT_ID", "platformSpecificData": { "geoRestriction": { "countries": ["US", "ES"] } } }] } ``` `geoRestriction.countries` accepts up to 25 uppercase ISO 3166-1 alpha-2 country codes. Zernio automatically resolves country codes to LinkedIn geo URNs. Currently supports 44 countries; unsupported country codes are silently skipped. ## LinkedIn Document Titles LinkedIn requires a title for document (PDF/carousel) posts. Set `platformSpecificData.documentTitle` to control the title shown on LinkedIn. > **Note:** If `documentTitle` is omitted, Zernio falls back to the media item `title`, then the `filename`. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Download our 2024 Industry Report", "mediaItems": [ {"type": "document", "url": "https://cdn.example.com/report.pdf"} ], "platforms": [{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "documentTitle": "2024 Industry Report" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Download our 2024 Industry Report', mediaItems: [ { type: 'document', url: 'https://cdn.example.com/report.pdf' } ], platforms: [{ platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { documentTitle: '2024 Industry Report' } }], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Download our 2024 Industry Report", media_items=[ {"type": "document", "url": "https://cdn.example.com/report.pdf"} ], platforms=[{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "documentTitle": "2024 Industry Report" } }], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ### Multi-Organization Posting If your connected LinkedIn account manages multiple organizations (company pages), you can post to different organizations from the same account connection. **List available organizations:** ```typescript const organizations = await zernio.connect.getLinkedInOrganizations('YOUR_ACCOUNT_ID'); console.log('Available organizations:', organizations); ``` ```python organizations = client.connect.list_linked_in_organizations("YOUR_ACCOUNT_ID") print("Available organizations:", organizations) ``` ```bash curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/linkedin-organizations \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Post to multiple organizations** using the same `accountId` with different `organizationUrn` values: ```typescript const { post } = await zernio.posts.createPost({ content: 'Exciting updates from our organization!', platforms: [ { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { organizationUrn: 'urn:li:organization:111111111' } }, { platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { organizationUrn: 'urn:li:organization:222222222' } } ], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Exciting updates from our organization!", platforms=[ { "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"organizationUrn": "urn:li:organization:111111111"} }, { "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"organizationUrn": "urn:li:organization:222222222"} } ], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Exciting updates from our organization!", "platforms": [ { "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "organizationUrn": "urn:li:organization:111111111" } }, { "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "organizationUrn": "urn:li:organization:222222222" } } ], "publishNow": true }' ``` The `organizationUrn` format is `urn:li:organization:` followed by the organization ID. ### First Comment Put external links in the first comment to avoid LinkedIn's link suppression algorithm: ```typescript const { post } = await zernio.posts.createPost({ content: 'We just published our guide to API design patterns.\n\nLink in the first comment.', platforms: [{ platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { firstComment: 'Read the full guide here: https://example.com/api-guide' } }], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="We just published our guide to API design patterns.\n\nLink in the first comment.", platforms=[{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "firstComment": "Read the full guide here: https://example.com/api-guide" } }], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We just published our guide to API design patterns.\n\nLink in the first comment.", "platforms": [{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "firstComment": "Read the full guide here: https://example.com/api-guide" } }], "publishNow": true }' ``` ### Disable Link Preview When posting text with URLs and no media, LinkedIn auto-generates a link preview card. To suppress it: ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out our latest blog post! https://example.com/blog/new-post', platforms: [{ platform: 'linkedin', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { disableLinkPreview: true } }], publishNow: true }); console.log('Posted to LinkedIn!', post._id); ``` ```python result = client.posts.create_post( content="Check out our latest blog post! https://example.com/blog/new-post", platforms=[{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "disableLinkPreview": True } }], publish_now=True ) post = result.post print(f"Posted to LinkedIn! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out our latest blog post! https://example.com/blog/new-post", "platforms": [{ "platform": "linkedin", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "disableLinkPreview": true } }], "publishNow": true }' ``` ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, it will not work. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages - Hosted on a fast, reliable CDN **Supabase URLs:** Zernio auto-proxies Supabase storage URLs, so they work without additional configuration. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Impressions | ✅ | | Reach | ✅ | | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Saves | ✅ (personal accounts only) | | Sends | ✅ (personal accounts only, LinkedIn messaging) | | Clicks | ✅ (organization accounts only) | | Views | ✅ (video posts only) | LinkedIn also provides dedicated analytics endpoints for deeper insights: - **[Personal Aggregate Analytics](/analytics/get-linkedin-aggregate-analytics)** -- Totals or daily time series across a personal account's posts. Covers impressions, reach, reactions, comments, shares, saves, sends. - **[Organization Aggregate Analytics](/analytics/get-linkedin-org-aggregate-analytics)** -- Impressions, clicks, reactions, engagement rate, follower gains (organic vs paid), and page-view metrics for organization pages. Requires the authenticated member to be an ADMINISTRATOR on the org and all three org scopes (r_organization_social + r_organization_followers + r_organization_admin). - **[Post Analytics](/analytics/get-linkedin-post-analytics)** -- Per-post metrics by URN. Works for both personal and organization accounts (saves and sends are personal-only, org returns 0). - **[Post Reactions](/analytics/get-linkedin-post-reactions)** -- Individual reactions with reactor profiles (organization accounts only). ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'linkedin', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="linkedin", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=linkedin&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through LinkedIn's API: - Publish long-form articles (linkedin.com/article/new/) - Create polls - Create events - Send InMail or DMs - Create newsletters - Add reactions to other posts - Create hashtag-following or tag connections - Mix media types (images + videos or images + documents in the same post) ## Common Errors LinkedIn has a **9.5% failure rate** across Zernio's platform (8,082 failures out of 85,512 attempts). Here are the most frequent errors and how to fix them: | Error | What it means | How to fix | |-------|---------------|------------| | "Content is a duplicate of urn:li:share:XXXX" (422) | Identical or very similar content was already posted | Modify the text meaningfully. LinkedIn's duplicate detection is strict -- even minor rephrasing may not be enough. | | "Publishing failed during preflight checks" | Rate limiting or validation caught an issue before publishing | Space posts further apart. Check account health in the dashboard. | | "Publishing failed due to max retries reached" | All 3 retry attempts failed | Temporary issue. Retry manually or check LinkedIn's status page. | | Token expired | OAuth access token has expired or been revoked | Reconnect the LinkedIn account. Subscribe to the `account.disconnected` webhook to catch this proactively. | | "Cannot mix media types" | Post contains images + videos or images + documents | Use only one media type per post. | | Video processing failed | Codec, duration, or aspect ratio is out of spec | Ensure codec is H.264, duration is within limits (10 min personal / 30 min company), and aspect ratio is between 1:2.4 and 2.4:1. | | Link preview showing wrong image | Open Graph meta tags on the URL are incorrect or missing | Update the `og:image` tag on your website. LinkedIn caches previews -- use the [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) to refresh. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). LinkedIn supports comments only (no DMs via API). ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like comments | ❌ (API restricted) | ### Limitations - **No DMs** - LinkedIn's messaging API is not available for third-party apps - **Organization accounts only** - Comments require an organization (company page) account type - **No comment likes** - Liking/reacting to comments via API is restricted. However, you can *read* post reactions via [`GET /v1/accounts/{accountId}/linkedin-post-reactions`](/analytics/get-linkedin-post-reactions) (organization accounts only). See [Comments API Reference](/comments/list-inbox-comments) for endpoint details. ## Related Endpoints - [Connect LinkedIn Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [LinkedIn Organizations](/linkedin-mentions/get-linkedin-mentions) - List company pages - [LinkedIn Mentions](/linkedin-mentions/get-linkedin-mentions) - Track brand mentions - [LinkedIn Aggregate Analytics](/analytics/get-analytics) - Organization-level analytics - [LinkedIn Post Analytics](/analytics/get-analytics) - Post-level performance metrics - [LinkedIn Post Reactions](/analytics/get-linkedin-post-reactions) - Who reacted to a post (organization accounts) - [Comments](/comments/list-inbox-comments) - Inbox API --- # Pinterest Ads Create Promoted Pin campaigns via Zernio API - No Pinterest developer approval required import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; **Included with the [Usage plan](/pricing).** No Pinterest developer approval needed. Zernio handles the advanced scopes registration. ## What's Supported | Feature | Status | |---------|--------| | Promoted Pin campaigns (Campaign > Ad Group > Pin) | Yes | | Boost existing organic Pins | Yes | | Demographic + interest targeting | Yes | | Real-time analytics (spend, saves, closeups, clicks) | Yes | | Pinterest Conversions API | Roadmap | | Catalog / Shopping ads | Roadmap | ## Create a Promoted Pin Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_pinterestads_123", adAccountId: "549123456789", name: "Spring home decor", goal: "traffic", budgetAmount: 30, budgetType: "daily", headline: "Freshen up your space", body: "Spring drops, limited time.", imageUrl: "https://cdn.example.com/spring-decor.jpg", linkUrl: "https://example.com/spring-decor", countries: ["US", "CA"], ageMin: 25, ageMax: 44, }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_pinterestads_123", ad_account_id="549123456789", name="Spring home decor", goal="traffic", budget_amount=30, budget_type="daily", headline="Freshen up your space", body="Spring drops, limited time.", image_url="https://cdn.example.com/spring-decor.jpg", link_url="https://example.com/spring-decor", countries=["US", "CA"], age_min=25, age_max=44, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_pinterestads_123", "adAccountId": "549123456789", "name": "Spring home decor", "goal": "traffic", "budgetAmount": 30, "budgetType": "daily", "headline": "Freshen up your space", "body": "Spring drops, limited time.", "imageUrl": "https://cdn.example.com/spring-decor.jpg", "linkUrl": "https://example.com/spring-decor", "countries": ["US", "CA"], "ageMin": 25, "ageMax": 44 }' ``` Pinterest creates a new Pin and promotes it in one call. Required: `headline` (max 100 chars), `body` (max 500 chars), `imageUrl`, `linkUrl`. `boardId` is optional, if omitted, a "Zernio Ads" board is auto-created. Valid `goal` values: `engagement`, `traffic`, `awareness`, `video_views`. To promote an *existing* Pin instead of creating one, use `/v1/ads/boost`. ## Boost an Existing Pin ```typescript const ad = await zernio.ads.boostPost({ body: { postId: "POST_ID", accountId: "ACCOUNT_ID", adAccountId: "549123456789", name: "Boost decor Pin", goal: "traffic", budget: { amount: 30, type: "daily" }, schedule: { startDate: "2026-04-20", endDate: "2026-05-20" }, }}); ``` ```python ad = client.ads.boost_post( post_id="POST_ID", account_id="ACCOUNT_ID", ad_account_id="549123456789", name="Boost decor Pin", goal="traffic", budget={"amount": 30, "type": "daily"}, schedule={"startDate": "2026-04-20", "endDate": "2026-05-20"}, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/boost" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "postId": "POST_ID", "accountId": "ACCOUNT_ID", "adAccountId": "549123456789", "name": "Boost decor Pin", "goal": "traffic", "budget": { "amount": 30, "type": "daily" }, "schedule": { "startDate": "2026-04-20", "endDate": "2026-05-20" } }' ``` ## Pinterest-Native Metrics Pinterest surfaces **saves** and **closeups** alongside standard click/spend metrics. These are included in `/v1/ads/{id}/analytics`. ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Standard Pin | JPEG, PNG | 20 MB | 1000x1500 (2:3) recommended | | Video Pin | MP4, MOV, M4V | 2 GB | 4s-15min, 9:16 or 1:1 | --- # Pinterest Schedule and automate Pinterest Pins with Zernio API - Image pins, video pins, boards, destination links, and cover images import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Title limit | 100 characters | | Description limit | 500 characters | | Images per pin | 1 | | Videos per pin | 1 | | Image formats | JPEG, PNG, WebP, GIF | | Image max size | 32 MB | | Video formats | MP4, MOV | | Video max size | 2 GB | | Video duration | 4 sec - 15 min | | Scheduling | Yes | | Inbox | No (Pinterest has no API-accessible inbox) | | Analytics | No (via Zernio) | ## Before You Start Pinterest is a **search engine**, not a social feed. Pins are discovered through search and browse, not by followers. This means SEO (title, description, board name) matters more than posting time. Pins have a 3-6 month lifespan, unlike hours on other platforms. Also: `boardId` is effectively required -- always provide it. Additional requirements: - A Pinterest Board is required to pin to - No text-only pins (media is required) - No carousels or multi-image posts (1 image or 1 video per pin) - The `link` field is critical for driving traffic ## Quick Start Create a Pin on Pinterest: ```typescript const { post } = await zernio.posts.createPost({ content: '10 Tips for Better Photography', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/pin-image.jpg' } ], platforms: [{ platform: 'pinterest', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: '10 Tips for Better Photography', boardId: 'YOUR_BOARD_ID', link: 'https://myblog.com/photography-tips' } }], publishNow: true }); console.log('Pin created!', post._id); ``` ```python result = client.posts.create_post( content="10 Tips for Better Photography", media_items=[ {"type": "image", "url": "https://cdn.example.com/pin-image.jpg"} ], platforms=[{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "10 Tips for Better Photography", "boardId": "YOUR_BOARD_ID", "link": "https://myblog.com/photography-tips" } }], publish_now=True ) post = result.post print(f"Pin created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "10 Tips for Better Photography", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/pin-image.jpg"} ], "platforms": [{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "10 Tips for Better Photography", "boardId": "YOUR_BOARD_ID", "link": "https://myblog.com/photography-tips" } }], "publishNow": true }' ``` ## Content Types ### Image Pin A single image pinned to a board. The most common pin type. Use 2:3 aspect ratio (1000x1500 px) for optimal display in the Pinterest feed. ```typescript const { post } = await zernio.posts.createPost({ content: 'Modern kitchen renovation ideas for small spaces', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/kitchen-ideas.jpg' } ], platforms: [{ platform: 'pinterest', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: 'Modern Kitchen Renovation Ideas', boardId: 'YOUR_BOARD_ID', link: 'https://myblog.com/kitchen-renovation' } }], publishNow: true }); console.log('Image pin created!', post._id); ``` ```python result = client.posts.create_post( content="Modern kitchen renovation ideas for small spaces", media_items=[ {"type": "image", "url": "https://cdn.example.com/kitchen-ideas.jpg"} ], platforms=[{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "Modern Kitchen Renovation Ideas", "boardId": "YOUR_BOARD_ID", "link": "https://myblog.com/kitchen-renovation" } }], publish_now=True ) post = result.post print(f"Image pin created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Modern kitchen renovation ideas for small spaces", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/kitchen-ideas.jpg"} ], "platforms": [{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "Modern Kitchen Renovation Ideas", "boardId": "YOUR_BOARD_ID", "link": "https://myblog.com/kitchen-renovation" } }], "publishNow": true }' ``` ### Video Pin A single video pinned to a board. You can optionally set a custom cover image or auto-extract a frame at a specific time. ```typescript const { post } = await zernio.posts.createPost({ content: 'Quick 5-minute breakfast recipe', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/recipe.mp4' } ], platforms: [{ platform: 'pinterest', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: '5-Minute Breakfast Recipe', boardId: 'YOUR_BOARD_ID', link: 'https://myrecipes.com/quick-breakfast', coverImageUrl: 'https://cdn.example.com/recipe-cover.jpg' } }], publishNow: true }); console.log('Video pin created!', post._id); ``` ```python result = client.posts.create_post( content="Quick 5-minute breakfast recipe", media_items=[ {"type": "video", "url": "https://cdn.example.com/recipe.mp4"} ], platforms=[{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "5-Minute Breakfast Recipe", "boardId": "YOUR_BOARD_ID", "link": "https://myrecipes.com/quick-breakfast", "coverImageUrl": "https://cdn.example.com/recipe-cover.jpg" } }], publish_now=True ) post = result.post print(f"Video pin created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Quick 5-minute breakfast recipe", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/recipe.mp4"} ], "platforms": [{ "platform": "pinterest", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "5-Minute Breakfast Recipe", "boardId": "YOUR_BOARD_ID", "link": "https://myrecipes.com/quick-breakfast", "coverImageUrl": "https://cdn.example.com/recipe-cover.jpg" } }], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 1 per pin | | **Formats** | JPEG, PNG, WebP, GIF | | **Max file size** | 32 MB | | **Recommended** | 1000 x 1500 px (2:3) | | **Min dimensions** | 100 x 100 px | #### Aspect Ratios | Ratio | Dimensions | Use Case | |-------|------------|----------| | 2:3 | 1000 x 1500 px | **Optimal** - Standard Pin | | 1:1 | 1000 x 1000 px | Square Pin | | 1:2.1 | 1000 x 2100 px | Long Pin (max height) | > **Best practice:** Use 2:3 aspect ratio for optimal display in the Pinterest feed. #### GIFs Pinterest supports animated GIFs. They auto-play in the feed and are treated as images (not video). Max file size is 32 MB, but keeping under 10 MB is recommended for fast loading. ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per pin | | **Formats** | MP4, MOV | | **Max file size** | 2 GB | | **Duration** | 4 seconds - 15 minutes | | **Aspect ratio** | 2:3, 1:1, or 9:16 | | **Resolution** | 1080p recommended | | **Frame rate** | 25+ fps | #### Video Specs | Property | Minimum | Recommended | |----------|---------|-------------| | Resolution | 240p | 1080p | | Bitrate | - | 10 Mbps | | Audio | - | AAC, 128 kbps | ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Pinterest platform entry. | Field | Type | Default | Description | |-------|------|---------|-------------| | `boardId` | string | -- | **Effectively required.** The board to pin to. Get board IDs via `GET /v1/accounts/{accountId}/pinterest-boards`. Aliases: `board_id`, `board`. | | `title` | string (max 100 chars) | First line of content | Pin title. Searchable by Pinterest users. | | `link` | string (URL) | -- | Destination link when users click the pin. Must be valid HTTPS. No URL shorteners. **Most important field for driving traffic.** Aliases: `url`. | | `coverImageUrl` | string (URL) | -- | Custom cover image for video pins. Aliases: `cover_image_url`, `thumbnailUrl`, `thumbnail_url`. | | `coverImageKeyFrameTime` | number (seconds) | `0` | Auto-extract a video frame at N seconds to use as cover. Ignored if `coverImageUrl` is set. | ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, it will not work. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages - Hosted on a fast, reliable CDN ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Impressions | ✅ | | Saves | ✅ | | Clicks | ✅ | ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'pinterest', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="pinterest", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=pinterest&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through Pinterest's API: - Create Idea Pins (multi-page stories) - Claim website - Create Rich Pins (requires meta tags on your website) - Access Pinterest Analytics (via Zernio) - Create Shopping catalogs - Edit or delete pins after creation (via Zernio) - Create multi-image posts or carousels ## Common Errors Pinterest has a **21.1% failure rate** across Zernio's platform (7,928 failures out of 37,646 attempts). Here are the most frequent errors and how to fix them: | Error | Meaning | Fix | |-------|---------|-----| | "Invalid URL or request data." | Pinterest could not process the URL or request data | Verify media URL is publicly accessible, returns actual media bytes, and uses HTTPS. | | "Unable to reach the URL. Please check the URL is correct and try again." | Pinterest's servers cannot fetch your media | Test the URL in an incognito browser. Ensure no authentication is required and there are no redirects to HTML pages. | | "Pinterest rate limit reached." | Too many API calls in a short window | Space out pins. Avoid bursts of 10+ pins at once. | | "Pinterest requires a boardId. Provide platformSpecificData.boardId." | No board was specified in the request | Always provide `boardId`. List available boards with `GET /v1/accounts/{accountId}/pinterest-boards`. | ## Inbox Pinterest does not have inbox features available via API. - **No DMs** -- Pinterest's messaging API is not available for third-party apps - **No comments** -- Pin comments are not accessible via API - **No reviews** -- Pinterest does not have a reviews system ## Related Endpoints - [Connect Pinterest Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Pin creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Pinterest Boards](/connect/get-pinterest-boards) - List boards for an account --- # Reddit Schedule and automate Reddit posts with Zernio API - Text posts, link posts, image posts, subreddit targeting, flair selection, and gallery posts import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Title limit | 300 characters (required, cannot edit after) | | Body text | 40,000 characters | | Images per post | 1 (single), multiple (gallery) | | Videos per post | 1 | | Image formats | JPEG, PNG, GIF | | Image max size | 20 MB | | Post types | Text, Link, Image, Gallery, Native Video | | Scheduling | Yes | | Inbox (DMs) | Yes (text only) | | Inbox (Comments) | Yes | | Analytics | No | ## Native Video Posts By default, Zernio uploads the video to Reddit's CDN and submits it as a native video, so it renders inside Reddit's embedded player. The alternative is a link post (`nativeVideo: false`), where the video URL appears as an external link out to wherever you host the file. Reddit transcodes native videos server-side (1080p/30fps cap). If a subreddit blocks video posts, the submission falls back to a link post automatically. ```typescript const { post } = await zernio.posts.createPost({ content: 'Demo: native video upload', mediaItems: [ { type: 'video', url: 'https://example.com/demo.mp4' } ], platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'videos', // nativeVideo: false, // uncomment to post as a link instead of uploading to Reddit } } ], publishNow: true }); console.log('Native video post created!', post._id); ``` ```python result = client.posts.create_post( content="Demo: native video upload", media_items=[ {"type": "video", "url": "https://example.com/demo.mp4"} ], platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "videos", # "nativeVideo": False, # uncomment to post as a link instead of uploading to Reddit } } ], publish_now=True ) post = result.post print(f"Native video post created! {post['_id']}") ``` ```bash # Default: native video upload (embedded Reddit player). # To post as a link instead, add "nativeVideo": false inside platformSpecificData. curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Demo: native video upload", "mediaItems": [ {"type": "video", "url": "https://example.com/demo.mp4"} ], "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "videos" } } ], "publishNow": true }' ``` ### Custom poster image By default Zernio auto-extracts the video's first frame as the poster. Pass `videoPosterUrl` to override it: ```json "platformSpecificData": { "subreddit": "videos", "videoPosterUrl": "https://example.com/poster.jpg" } ``` ### Silent looping clips (videogif) Set `videogif: true` to submit as a silent looping clip. Still uploaded as native video, `videogif` only changes the Reddit kind. ```typescript await zernio.posts.createPost({ content: 'Short looping clip (videogif)', mediaItems: [ { type: 'video', url: 'https://example.com/loop.mp4' } ], platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'gifs', videogif: true } } ], publishNow: true }); ``` ```python client.posts.create_post( content="Short looping clip (videogif)", media_items=[ {"type": "video", "url": "https://example.com/loop.mp4"} ], platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "gifs", "videogif": True } } ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Short looping clip (videogif)", "mediaItems": [ {"type": "video", "url": "https://example.com/loop.mp4"} ], "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "gifs", "videogif": true } } ], "publishNow": true }' ``` ## Before You Start Reddit is fundamentally different from every other platform. Each subreddit (community) is independently moderated with its **own rules**. There is no universal set of rules. What works in one subreddit will get you banned in another. Before posting to any subreddit via API, you **must**: 1. Check if the subreddit allows your post type (text, link, or image) 2. Check if flair is required (many subreddits auto-remove posts without flair) 3. Check if the subreddit allows third-party/automated posting 4. Check karma and account age requirements More than half of all Reddit posts via Zernio fail. Almost every failure is preventable by reading the target subreddit's rules first. Additional warnings: - **Title is permanent** -- Reddit titles cannot be edited after posting - **New accounts are restricted** -- Low karma and new account age will block most subreddits - **Video rules vary by subreddit** -- Some subreddits block video posts; in those cases Zernio falls back to a link post automatically - **Each subreddit has unique, independent rules** -- Always check before posting ## Quick Start Post to Reddit in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: "What is your favorite programming language and why?\n\nI have been using Python for years but considering learning Rust.", platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'learnprogramming' } } ], publishNow: true }); console.log('Posted to Reddit!', post._id); ``` ```python result = client.posts.create_post( content="What is your favorite programming language and why?\n\nI have been using Python for years but considering learning Rust.", platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "learnprogramming" } } ], publish_now=True ) post = result.post print(f"Posted to Reddit! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "What is your favorite programming language and why?\n\nI have been using Python for years but considering learning Rust.", "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "learnprogramming" } } ], "publishNow": true }' ``` ## Content Types ### Text Post (Self Post) A text post with a title and optional body. This is the default post type when no media or URL is provided. The first line of `content` becomes the title, and the rest becomes the body. Reddit Markdown is supported in the body. ```typescript const { post } = await zernio.posts.createPost({ content: "Tips for learning a new programming language\n\nHere are some things that worked for me:\n\n1. Start with the official tutorial\n2. Build a small project immediately\n3. Read other people's code\n4. Contribute to open source", platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'learnprogramming' } } ], publishNow: true }); console.log('Text post created!', post._id); ``` ```python result = client.posts.create_post( content="Tips for learning a new programming language\n\nHere are some things that worked for me:\n\n1. Start with the official tutorial\n2. Build a small project immediately\n3. Read other people's code\n4. Contribute to open source", platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "learnprogramming" } } ], publish_now=True ) post = result.post print(f"Text post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Tips for learning a new programming language\n\nHere are some things that worked for me:\n\n1. Start with the official tutorial\n2. Build a small project immediately\n3. Read other people'\''s code\n4. Contribute to open source", "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "learnprogramming" } } ], "publishNow": true }' ``` ### Link Post Share an external URL. When `platformSpecificData.url` is provided, Reddit creates a link post instead of a text post. The `content` becomes the post title. ```typescript const { post } = await zernio.posts.createPost({ content: 'Interesting article about modern API design patterns', platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'programming', url: 'https://example.com/api-design-article' } } ], publishNow: true }); console.log('Link post created!', post._id); ``` ```python result = client.posts.create_post( content="Interesting article about modern API design patterns", platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "programming", "url": "https://example.com/api-design-article" } } ], publish_now=True ) post = result.post print(f"Link post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Interesting article about modern API design patterns", "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "programming", "url": "https://example.com/api-design-article" } } ], "publishNow": true }' ``` ### Image Post Attach a single image to a post. The `content` becomes the post title. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this view from my hike!', mediaItems: [ { type: 'image', url: 'https://example.com/hiking-photo.jpg' } ], platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'hiking' } } ], publishNow: true }); console.log('Image post created!', post._id); ``` ```python result = client.posts.create_post( content="Check out this view from my hike!", media_items=[ {"type": "image", "url": "https://example.com/hiking-photo.jpg"} ], platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "hiking" } } ], publish_now=True ) post = result.post print(f"Image post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this view from my hike!", "mediaItems": [ {"type": "image", "url": "https://example.com/hiking-photo.jpg"} ], "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "hiking" } } ], "publishNow": true }' ``` ### Gallery Post Attach multiple images to create a gallery post. Not all subreddits support galleries. ```typescript const { post } = await zernio.posts.createPost({ content: 'My weekend woodworking project - start to finish', mediaItems: [ { type: 'image', url: 'https://example.com/step1.jpg' }, { type: 'image', url: 'https://example.com/step2.jpg' }, { type: 'image', url: 'https://example.com/step3.jpg' }, { type: 'image', url: 'https://example.com/finished.jpg' } ], platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'woodworking' } } ], publishNow: true }); console.log('Gallery post created!', post._id); ``` ```python result = client.posts.create_post( content="My weekend woodworking project - start to finish", media_items=[ {"type": "image", "url": "https://example.com/step1.jpg"}, {"type": "image", "url": "https://example.com/step2.jpg"}, {"type": "image", "url": "https://example.com/step3.jpg"}, {"type": "image", "url": "https://example.com/finished.jpg"} ], platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "woodworking" } } ], publish_now=True ) post = result.post print(f"Gallery post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "My weekend woodworking project - start to finish", "mediaItems": [ {"type": "image", "url": "https://example.com/step1.jpg"}, {"type": "image", "url": "https://example.com/step2.jpg"}, {"type": "image", "url": "https://example.com/step3.jpg"}, {"type": "image", "url": "https://example.com/finished.jpg"} ], "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "woodworking" } } ], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 1 per post (single), multiple (gallery) | | **Formats** | JPEG, PNG, GIF | | **Max file size** | 20 MB | | **Recommended** | 1200 x 628 px | ### Aspect Ratios Reddit is flexible with aspect ratios: | Ratio | Use Case | |-------|----------| | 16:9 | Standard landscape | | 4:3 | Classic format | | 1:1 | Square images | | 9:16 | Mobile screenshots | ### GIFs - Static display until clicked - Max 20 MB - May convert to video format internally - Keep under 10 MB for better performance ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Reddit platform entry. | Field | Type | Required | Description | |-------|------|----------|-------------| | `subreddit` | string | Effectively yes | Target subreddit (without `r/` prefix). Falls back to account default if omitted. Aliases: `subredditName`, `sr` | | `title` | string | Yes (by Reddit) | Post title, max 300 characters. Cannot be edited after posting. If omitted, first line of `content` is used. | | `url` | string | No | External URL for link posts. If provided, creates a link post instead of a text post. | | `nativeVideo` | boolean | No | Defaults to `true` for video media items (uploads to Reddit's CDN, renders as embedded player). Set `false` to post as a plain link instead. | | `videogif` | boolean | No | When `true`, submit the native video as a silent looping videogif. | | `videoPosterUrl` | string | No | Custom poster/thumbnail. If omitted, Zernio auto-extracts the video's first frame. | | `flairId` | string | Varies | Reddit flair ID. Many subreddits require this. Get flairs via `GET /v1/accounts/{accountId}/reddit-flairs?subreddit=NAME`. Aliases: `redditFlairId` | | `flairText` | string | No | Custom flair text for subreddits that allow free-text flair. Ignored when `flairId` is provided (`flairId` wins). | | `nsfw` | boolean | No | Mark the post as NSFW (Not Safe For Work / over 18). | | `spoiler` | boolean | No | Mark the post as a spoiler (subreddit must have spoiler tagging enabled). | | `sendreplies` | boolean | No | Whether to receive inbox replies for comments on this post. Set to `false` to opt out. | | `forceSelf` | boolean | No | Force text/self post even when media is attached. | ### Subreddit Selection Posts are submitted to the subreddit configured on the connected Reddit account by default. To post to a specific subreddit, set `platformSpecificData.subreddit` (without the `r/` prefix). ### Post Flairs Some subreddits require a post flair. First, list available flairs for a subreddit: ```typescript const { flairs } = await zernio.connect.getRedditFlairs('YOUR_ACCOUNT_ID', { subreddit: 'socialmedia' }); console.log(flairs); ``` ```python result = client.connect.get_reddit_flairs( "YOUR_ACCOUNT_ID", subreddit="socialmedia" ) print(result.flairs) ``` ```bash curl -X GET "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/reddit-flairs?subreddit=socialmedia" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Then, create a post with `flairId`: ```typescript const { post } = await zernio.posts.createPost({ content: 'What is your favorite programming language and why?', platforms: [ { platform: 'reddit', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { subreddit: 'socialmedia', flairId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' } } ], publishNow: true }); console.log('Posted to Reddit!', post._id); ``` ```python result = client.posts.create_post( content="What is your favorite programming language and why?", platforms=[ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "socialmedia", "flairId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ], publish_now=True ) post = result.post print(f"Posted to Reddit! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "What is your favorite programming language and why?", "platforms": [ { "platform": "reddit", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "subreddit": "socialmedia", "flairId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ], "publishNow": true }' ``` If a subreddit requires a flair and you do not provide `flairId`, Zernio will attempt to use the first available flair as a fallback. ## Formatting Reddit supports Markdown in text post bodies: ```markdown # Heading ## Subheading **Bold text** *Italic text* ~~Strikethrough~~ - Bullet points 1. Numbered lists [Link text](https://example.com) > Block quotes `inline code` ``` ## Auto-Retry Behavior Zernio automatically retries failed Reddit posts in certain situations: - **Link post in text-only subreddit** -- If a link post fails with a `NO_LINKS` error (subreddit only allows text posts), Zernio auto-retries as a text/self post with the URL included in the body. - **Missing required flair** -- If a subreddit requires flair but none was provided, Zernio tries to use the first available flair automatically. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Likes (upvotes) | ✅ | | Comments | ✅ | Reddit does not provide impressions, reach, shares, clicks, or view counts through its API. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'reddit', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="reddit", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=reddit&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through Reddit's API: - Create polls - Crosspost to other subreddits - Edit post title after creation - Add post to collections - Create live chat threads - Award/give gold to posts - See upvote/downvote counts (score only) ## Common Errors Reddit has a **53.9% failure rate** across Zernio's platform (4,785 failures out of 8,877 attempts). Here are the most frequent errors and how to fix them: | Error | What it means | How to fix | |-------|---------------|------------| | "SUBREDDIT_NOTALLOWED: only trusted members" | Subreddit restricts who can post | Join the community, build karma, or choose a different subreddit | | "NO_SELFS: doesn't allow text posts" | Subreddit only accepts link or image posts | Provide a URL via `platformSpecificData.url` or attach an image | | "SUBMIT_VALIDATION_FLAIR_REQUIRED" | Subreddit requires flair on all posts | Fetch flairs with `GET /v1/accounts/{accountId}/reddit-flairs?subreddit=NAME` and provide the correct `flairId` | | "SUBREDDIT_NOEXIST" | Typo in subreddit name or subreddit is private | Check spelling, do not include the `r/` prefix | | "AI-generated content not allowed" | Subreddit bans AI-generated content | Write original content or choose a different subreddit | | "Reddit removed the post" | Moderators or AutoMod removed the post after submission | Check subreddit rules, ensure content complies | | "Reddit requires a subreddit" | No subreddit was specified and no default is set | Always provide `platformSpecificData.subreddit` | | "Rate limited" | Reddit's rate limit was hit | Space posts further apart. New accounts are limited to around 10 posts per day. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Reddit supports DMs (private messages) and comments. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | ✅ | | Fetch messages | ✅ | | Send text messages | ✅ | | Send attachments | ❌ (API limitation) | | Archive/unarchive | ✅ | ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Upvote/downvote | ✅ | | Remove vote | ✅ | ### Limitations - **No DM attachments** - Reddit's API does not support media in private messages - **Subreddit required** - When replying to comments, you must provide the `subreddit` parameter See [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) API Reference for endpoint details. ## Related Endpoints - [Connect Reddit Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image uploads - [Reddit Search](/reddit-search/search-reddit) - Search Reddit content - [Reddit Subreddits](/connect/get-reddit-subreddits) - List subscribed subreddits - [Reddit Flairs](/connect/get-reddit-flairs) - Get available flairs for a subreddit - [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) - Inbox API --- # Snapchat Schedule and automate Snapchat posts with Zernio API - Stories, Saved Stories, Spotlight content, and Public Profile management import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Title limit | 45 chars (Saved Stories) | | Description limit | 160 chars (Spotlight, including hashtags) | | Media per post | 1 (single image or video only) | | Image formats | JPEG, PNG | | Image max size | 20 MB | | Video format | MP4 only | | Video max size | 500 MB | | Video duration | 5-60 seconds | | Post types | Story, Saved Story, Spotlight | | Scheduling | Yes | | Inbox | No | | Analytics | Yes (views, viewers, screenshots, shares) | ## Before You Start Snapchat requires a Public Profile to publish content. Regular accounts cannot use the API. Also: Snapchat only supports 1 media item per post -- no carousels, no albums. This is the most restrictive platform for content format. Additional requirements: - Public Profile required (Person, Business, or Official) - Single media item only (most restrictive platform) - No text-only posts - 9:16 vertical orientation practically required - Media is encrypted (AES-256-CBC) before upload (handled by Zernio) ## Quick Start Post to Snapchat in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [{ platform: 'snapchat', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'story' } }], publishNow: true }); console.log('Posted to Snapchat!', post._id); ``` ```python result = client.posts.create_post( media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], publish_now=True ) post = result.post print(f"Posted to Snapchat! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], "publishNow": true }' ``` ## Content Types Snapchat supports three content types through the Public Profile API: | Type | Description | Duration | Text Support | |------|-------------|----------|--------------| | `story` | Ephemeral snap visible for 24 hours | Temporary | No caption | | `saved_story` | Permanent story on Public Profile | Permanent | Title (max 45 chars) | | `spotlight` | Video in Snapchat's entertainment feed | Permanent | Description (max 160 chars, hashtags supported) | ### Story Posts Stories are ephemeral content visible for 24 hours. No caption or text is supported. ```typescript const { post } = await zernio.posts.createPost({ mediaItems: [ { type: 'image', url: 'https://example.com/image.jpg' } ], platforms: [{ platform: 'snapchat', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'story' } }], publishNow: true }); console.log('Posted to Snapchat!', post._id); ``` ```python result = client.posts.create_post( media_items=[ {"type": "image", "url": "https://example.com/image.jpg"} ], platforms=[{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], publish_now=True ) post = result.post print(f"Posted to Snapchat! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mediaItems": [ {"type": "image", "url": "https://example.com/image.jpg"} ], "platforms": [{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "story" } }], "publishNow": true }' ``` ### Saved Story Posts Saved Stories are permanent content displayed on your Public Profile. The post `content` is used as the title (max 45 characters). ```typescript const { post } = await zernio.posts.createPost({ content: 'Behind the scenes look!', mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [{ platform: 'snapchat', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'saved_story' } }], publishNow: true }); console.log('Posted to Snapchat!', post._id); ``` ```python result = client.posts.create_post( content="Behind the scenes look!", media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "saved_story" } }], publish_now=True ) post = result.post print(f"Posted to Snapchat! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes look!", "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "saved_story" } }], "publishNow": true }' ``` ### Spotlight Posts Spotlight is Snapchat's TikTok-like entertainment feed. Only video content is supported. The post `content` is used as the description (max 160 characters) and can include hashtags. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this amazing sunset! #sunset #nature #viral', mediaItems: [ { type: 'video', url: 'https://example.com/sunset-video.mp4' } ], platforms: [{ platform: 'snapchat', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { contentType: 'spotlight' } }], publishNow: true }); console.log('Posted to Snapchat!', post._id); ``` ```python result = client.posts.create_post( content="Check out this amazing sunset! #sunset #nature #viral", media_items=[ {"type": "video", "url": "https://example.com/sunset-video.mp4"} ], platforms=[{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "spotlight" } }], publish_now=True ) post = result.post print(f"Posted to Snapchat! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this amazing sunset! #sunset #nature #viral", "mediaItems": [ {"type": "video", "url": "https://example.com/sunset-video.mp4"} ], "platforms": [{ "platform": "snapchat", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "contentType": "spotlight" } }], "publishNow": true }' ``` ## Media Requirements Media is required for all Snapchat posts. Text-only posts are not supported. ### Images | Property | Requirement | |----------|-------------| | **Formats** | JPEG, PNG | | **Max File Size** | 20 MB | | **Recommended Dimensions** | 1080 x 1920 px | | **Aspect Ratio** | 9:16 (portrait) | ### Videos | Property | Requirement | |----------|-------------| | **Format** | MP4 | | **Max File Size** | 500 MB | | **Duration** | 5-60 seconds | | **Min Resolution** | 540 x 960 px | | **Recommended Dimensions** | 1080 x 1920 px | | **Aspect Ratio** | 9:16 (portrait) | Media is automatically encrypted using AES-256-CBC before upload to Snapchat. This is handled entirely by Zernio. ## Platform-Specific Fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `contentType` | string | `"story"` | Content type: `"story"`, `"saved_story"`, or `"spotlight"` | ## Connection Snapchat uses OAuth for authentication and requires selecting a Public Profile to publish content. ### Standard Flow Redirect users to the Zernio OAuth URL: ``` https://zernio.com/connect/snapchat?profileId=YOUR_PROFILE_ID&redirect_url=https://yourapp.com/callback ``` After authorization, users select a Public Profile, and Zernio redirects back to your `redirect_url` with connection details. ### Headless Mode (Custom UI) Build your own fully-branded Public Profile selector: #### Step 1: Initiate OAuth ``` https://zernio.com/api/v1/connect/snapchat?profileId=YOUR_PROFILE_ID&redirect_url=https://yourapp.com/callback&headless=true ``` After OAuth, you'll be redirected to your `redirect_url` with: - `tempToken` - Temporary access token - `userProfile` - URL-encoded JSON with user info - `publicProfiles` - URL-encoded JSON array of available Public Profiles - `connect_token` - Short-lived token for API authentication - `platform=snapchat` - `step=select_public_profile` #### Step 2: List Public Profiles ```python result = client.connect.list_snapchat_profiles( profile_id="YOUR_PROFILE_ID", temp_token=temp_token, x_connect_token=connect_token ) public_profiles = result['publicProfiles'] # Display profiles in your custom UI ``` ```bash curl -X GET "https://zernio.com/api/v1/connect/snapchat/select-profile?profileId=YOUR_PROFILE_ID&tempToken=TEMP_TOKEN" \ -H "X-Connect-Token: CONNECT_TOKEN" ``` **Response:** ```json { "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 } ] } ``` #### Step 3: Select Public Profile ```python result = client.connect.select_snapchat_profile( profile_id="YOUR_PROFILE_ID", selected_public_profile={ "id": "abc123-def456", "display_name": "My Brand", "username": "mybrand" }, temp_token=temp_token, user_profile=user_profile, x_connect_token=connect_token ) print(f"Connected: {result['account']['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/connect/snapchat/select-profile \ -H "X-Connect-Token: CONNECT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "selectedPublicProfile": { "id": "abc123-def456", "display_name": "My Brand", "username": "mybrand" }, "tempToken": "TEMP_TOKEN", "userProfile": { "id": "user123", "username": "mybrand", "displayName": "My Brand" } }' ``` **Response:** ```json { "message": "Snapchat connected successfully with public profile", "account": { "platform": "snapchat", "username": "mybrand", "displayName": "My Brand", "profilePicture": "https://cf-st.sc-cdn.net/...", "isActive": true, "publicProfileName": "My Brand" } } ``` ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Reach (unique viewers) | ✅ | | Shares | ✅ | | Views | ✅ | | Screenshots | ✅ | | Completion Rate | ✅ | Analytics are fetched per content type (story, saved_story, spotlight). ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'snapchat', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="snapchat", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=snapchat&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through Snapchat's API: - Use AR lenses or filters - Create ads - Access Snap Map features - Use Snapchat sounds - Create collaborative stories - Access friend stories - Send DMs or read comments - Post text-only content (media required) - Post multiple media items (single item only) ## Common Errors | Error | Meaning | Fix | |-------|---------|-----| | "Public Profile required" | Account does not have a Public Profile set up | Ensure the Snapchat account has a Public Profile (Person, Business, or Official) and select it during the connection flow. | | "Media is required" | Post was submitted without any media | Add an image or video. Snapchat does not support text-only posts. | | "Only one media item supported" | Multiple media items were included | Remove extra media items. Snapchat only supports a single image or video per post. | | Video rejected | Video does not meet Snapchat's requirements | Check duration (5-60 sec), format (MP4 only), minimum resolution (540 x 960 px), and file size (under 500 MB). | | "Title too long" (Saved Stories) | Title exceeds 45 characters | Shorten the `content` field to 45 characters or fewer. | | "Description too long" (Spotlight) | Description exceeds 160 characters | Shorten the `content` field to 160 characters or fewer, including hashtags. | ## Inbox Snapchat does not have inbox features available via API. - **No DMs** - Snapchat's messaging API is not available for third-party apps - **No comments** - Snap comments are not accessible via API ## Related Endpoints - [Connect Snapchat Account](/guides/connecting-accounts) - OAuth connection flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Fetch post analytics --- # Telegram Schedule and automate Telegram channel and group posts with Zernio API - Text, images, videos, media albums, silent messages, and bot management import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Text limit | 4,096 characters (text messages) | | Caption limit | 1,024 characters (media captions) | | Images per album | 10 | | Videos per album | 10 | | Mixed media | Yes (images + videos in same album) | | Image formats | JPEG, PNG, GIF, WebP | | Image max size | 10 MB (auto-compressed) | | Video formats | MP4, MOV | | Video max size | 50 MB (auto-compressed) | | Scheduling | Yes | | Inbox (DMs) | Yes (full featured) | | Inbox (Comments) | No | | Analytics | No (Telegram limitation) | ## Before You Start Telegram requires **@ZernioScheduleBot** to be an administrator in your channel or group with **post permissions**. This is the number one setup failure. Also: posts in groups show as sent by "ZernioScheduleBot", not by you. In channels, posts show as the channel name. Additional requirements: - Bot-based integration (not OAuth). Uses `@ZernioScheduleBot` - The bot must be added as an admin with post permissions before you can publish - **Channels:** posts appear as the channel name and logo (correct behavior) - **Groups:** posts appear as "ZernioScheduleBot" (cannot be changed) ## Quick Start Post to a Telegram channel or group: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello from Zernio API! Check out our latest update.', platforms: [ { platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Telegram!', post._id); ``` ```python result = client.posts.create_post( content="Hello from Zernio API! Check out our latest update.", platforms=[ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Telegram! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from Zernio API! Check out our latest update.", "platforms": [ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text Message Send a formatted text message with HTML, Markdown, or MarkdownV2: ```typescript const { post } = await zernio.posts.createPost({ content: 'Important Update!\n\nCheck out our new feature.', platforms: [{ platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { parseMode: 'HTML' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content='Important Update!\n\nCheck out our new feature.', platforms=[{ "platform": "telegram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "parseMode": "HTML" } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Important Update!\n\nCheck out our new feature.", "platforms": [{ "platform": "telegram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "parseMode": "HTML" } }], "publishNow": true }' ``` ### Photo Message Send a single image with an optional caption: ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { type: 'image', url: 'https://example.com/image.jpg' } ], platforms: [ { platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Check out this photo!", media_items=[ {"type": "image", "url": "https://example.com/image.jpg"} ], platforms=[ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this photo!", "mediaItems": [ {"type": "image", "url": "https://example.com/image.jpg"} ], "platforms": [ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Video Message Send a single video with an optional caption: ```typescript const { post } = await zernio.posts.createPost({ content: 'Watch our latest video!', mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [ { platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Watch our latest video!", media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Watch our latest video!", "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Document Message Send any file type as a document: ```typescript const { post } = await zernio.posts.createPost({ content: 'Here is the report.', mediaItems: [ { type: 'document', url: 'https://example.com/report.pdf' } ], platforms: [ { platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Here is the report.", media_items=[ {"type": "document", "url": "https://example.com/report.pdf"} ], platforms=[ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Here is the report.", "mediaItems": [ {"type": "document", "url": "https://example.com/report.pdf"} ], "platforms": [ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Media Album Send up to 10 items in a single album. Images and videos can be mixed: ```typescript const { post } = await zernio.posts.createPost({ content: 'Our latest product gallery!', mediaItems: [ { type: 'image', url: 'https://example.com/image1.jpg' }, { type: 'image', url: 'https://example.com/image2.jpg' }, { type: 'video', url: 'https://example.com/video.mp4' }, { type: 'image', url: 'https://example.com/image3.jpg' } ], platforms: [ { platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Our latest product gallery!", media_items=[ {"type": "image", "url": "https://example.com/image1.jpg"}, {"type": "image", "url": "https://example.com/image2.jpg"}, {"type": "video", "url": "https://example.com/video.mp4"}, {"type": "image", "url": "https://example.com/image3.jpg"} ], platforms=[ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our latest product gallery!", "mediaItems": [ {"type": "image", "url": "https://example.com/image1.jpg"}, {"type": "image", "url": "https://example.com/image2.jpg"}, {"type": "video", "url": "https://example.com/video.mp4"}, {"type": "image", "url": "https://example.com/image3.jpg"} ], "platforms": [ {"platform": "telegram", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max per album** | 10 | | **Formats** | JPEG, PNG, GIF, WebP | | **Max file size** | 10 MB (auto-compressed) | | **Max resolution** | 10,000 x 10,000 px | ### Videos | Property | Requirement | |----------|-------------| | **Max per album** | 10 | | **Formats** | MP4, MOV | | **Max file size** | 50 MB (auto-compressed) | | **Max duration** | No limit | | **Codec** | H.264 recommended | ## Platform-Specific Fields All fields go inside `platformSpecificData` for the Telegram platform entry: | Field | Type | Default | Description | |-------|------|---------|-------------| | `parseMode` | string | `"HTML"` | Text formatting mode: `"HTML"`, `"Markdown"`, or `"MarkdownV2"` | | `disableWebPagePreview` | boolean | `false` | Prevents link preview generation for URLs in the message | | `disableNotification` | boolean | `false` | Sends the message silently (recipients get no notification sound) | | `protectContent` | boolean | `false` | Prevents the message from being forwarded or saved by recipients | ```typescript const { post } = await zernio.posts.createPost({ content: 'Important Update!\n\nCheck out our new feature.', platforms: [{ platform: 'telegram', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { parseMode: 'HTML', disableWebPagePreview: false, disableNotification: true, protectContent: true } }], publishNow: true }); ``` ```python result = client.posts.create_post( content='Important Update!\n\nCheck out our new feature.', platforms=[{ "platform": "telegram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "parseMode": "HTML", "disableWebPagePreview": False, "disableNotification": True, "protectContent": True } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Important Update!\n\nCheck out our new feature.", "platforms": [{ "platform": "telegram", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "parseMode": "HTML", "disableWebPagePreview": false, "disableNotification": true, "protectContent": true } }], "publishNow": true }' ``` ## Connection Zernio provides a managed bot (`@ZernioScheduleBot`) for Telegram integration. No need to create your own bot -- just add Zernio's bot to your channel or group. ### Option 1: Access Code Flow (Recommended) This is the easiest way to connect a Telegram channel or group. #### Step 1: Generate an Access Code ```typescript const { code, botUsername, instructions } = await zernio.connect.getConnectUrl({ platform: 'telegram', profileId: 'YOUR_PROFILE_ID' }); console.log(`Your access code: ${code}`); console.log(`Bot to message: @${botUsername}`); ``` ```python result = client.connect.get_connect_url( platform="telegram", profile_id="YOUR_PROFILE_ID" ) print(f"Your access code: {result.code}") print(f"Bot to message: @{result.bot_username}") ``` ```bash curl -X GET "https://zernio.com/api/v1/connect/telegram?profileId=YOUR_PROFILE_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response:** ```json { "code": "ZERNIO-ABC123", "expiresAt": "2025-01-15T12:30:00.000Z", "expiresIn": 900, "botUsername": "ZernioScheduleBot", "instructions": [ "1. Add @ZernioScheduleBot as an administrator in your channel/group", "2. Open a private chat with @ZernioScheduleBot", "3. Send: ZERNIO-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" ] } ``` #### Step 2: Add the Bot to Your Channel/Group **For Channels:** 1. Go to your channel settings 2. Add `@ZernioScheduleBot` as an **Administrator** 3. Grant permission to **Post Messages** **For Groups:** 1. Add `@ZernioScheduleBot` to the group 2. Make the bot an **Administrator** (required for posting) #### Step 3: Send the Access Code 1. Open a private chat with [@ZernioScheduleBot](https://t.me/ZernioScheduleBot) 2. Send your access code with your channel: `ZERNIO-ABC123 @yourchannel` 3. For private channels without a username, forward any message from the channel to the bot along with the code #### Step 4: Poll for Connection Status ```bash curl -X PATCH "https://zernio.com/api/v1/connect/telegram?code=ZERNIO-ABC123" \ -H "Authorization: Bearer YOUR_API_KEY" ``` The published Node and Python SDKs auto-generate signatures for these Telegram connect-status endpoints that don't match the actual request shape (the endpoint takes `code` as a query param). Until the next regen catches up, hit the endpoint directly via fetch / requests. **Status Response (Pending):** ```json { "status": "pending", "expiresAt": "2025-01-15T12:30:00.000Z", "expiresIn": 542 } ``` **Status Response (Connected):** ```json { "status": "connected", "chatId": "-1001234567890", "chatTitle": "My Channel", "chatType": "channel", "account": { "_id": "64e1f0a9e2b5af0012ab34cd", "platform": "telegram", "username": "mychannel", "displayName": "My Channel" } } ``` ### Option 2: Direct Connection (Power Users) If you already know your chat ID and the Zernio bot is already an administrator in your channel/group: The published Node and Python SDKs auto-generate signatures for these Telegram connect endpoints that don't match the actual request shape. Until the next regen catches up, hit the endpoint directly. ```bash curl -X POST https://zernio.com/api/v1/connect/telegram \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "chatId": "-1001234567890" }' ``` **Response:** ```json { "message": "Telegram channel connected successfully", "account": { "_id": "64e1f0a9e2b5af0012ab34cd", "platform": "telegram", "username": "mychannel", "displayName": "My Channel", "isActive": true, "chatType": "channel" } } ``` ### Finding Your Chat ID **For Public Channels:** - Use the channel username with `@` prefix: `@mychannel` **For Private Channels:** - Forward a message from the channel to [@userinfobot](https://t.me/userinfobot) - The bot will reply with the numeric chat ID (starts with `-100`) **For Groups:** - Add [@userinfobot](https://t.me/userinfobot) to your group temporarily - It will display the group's chat ID (negative number) - Remove the bot after getting the ID ## Text Formatting ### HTML Mode (Default) ```html bold italic underline strikethrough inline code
code block
link ``` ### Markdown Mode ```markdown *bold* _italic_ [link](https://example.com) `inline code` ``` ### MarkdownV2 Mode ```markdown *bold* _italic_ __underline__ ~strikethrough~ ||spoiler|| `inline code` ``` > **Note:** MarkdownV2 requires escaping special characters: `_`, `*`, `[`, `]`, `(`, `)`, `~`, `` ` ``, `>`, `#`, `+`, `-`, `=`, `|`, `{`, `}`, `.`, `!` ## Channel vs Group Posts | Destination | Author Display | |-------------|----------------| | **Channel** | Channel name and logo | | **Group** | Bot name (ZernioScheduleBot) | When posting to a **channel**, the post appears as if sent by the channel itself. When posting to a **group**, the post shows as sent by the Zernio bot. ## Analytics Telegram does not provide analytics through its Bot API. View counts for channel posts are only visible within the Telegram app. For messaging metrics, use Telegram's native channel statistics (available for channels with 500+ subscribers). ## What You Can't Do - Create polls or quizzes via Zernio - Schedule messages natively through Telegram (use Zernio scheduling instead) - Manage channel administrators - See message analytics (Telegram platform limitation) - Pin messages - Create channel invite links ## Common Errors | Error | Cause | Fix | |-------|-------|-----| | "Bot is not a member of the channel" | `@ZernioScheduleBot` is not added to the channel/group or is not an admin | Add the bot as an administrator and grant post permissions | | "Message is too long" | Text exceeds 4,096 characters or caption exceeds 1,024 characters | Shorten the content or split into multiple messages | | "Wrong file identifier/HTTP URL specified" | Media URL is inaccessible, uses HTTP, or redirects | Use a direct HTTPS URL that is publicly accessible with no redirects | | "Can't parse entities" | Invalid HTML/Markdown syntax or unescaped special characters | Check tag closure in HTML mode; escape special characters in MarkdownV2 | | Media not displaying | Unsupported format or file exceeds size limit | Verify format is supported and size is within limits (10 MB images, 50 MB videos) | | "Access code expired" | Code was not used within 15 minutes | Generate a new access code with `GET /v1/connect/telegram` | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Telegram supports DMs with full attachment support. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | Yes | | Fetch messages | Yes | | Send text messages | Yes | | Send attachments | Yes (images, videos, documents) | | Edit messages | Yes (text and inline keyboard) | | Inline keyboards | Yes (buttons with callback data or URLs) | | Reply keyboards | Yes (one-time custom keyboards) | | Reply to message | Yes (via `replyTo` message ID) | | Archive/unarchive | Yes | ### Attachment Support | Type | Supported | Max Size | |------|-----------|----------| | Images | Yes | 10 MB | | Videos | Yes | 50 MB | | Documents | Yes | 50 MB | ### Bot Commands Manage the bot command menu shown in Telegram chats. Commands appear in the "/" menu when users interact with the bot. See [Account Settings](/account-settings/get-telegram-commands) for the `GET/PUT/DELETE /v1/accounts/{accountId}/telegram-commands` endpoints. ### Webhooks | Event | When it fires | |-------|---------------| | `message.received` | New incoming message to the bot | | `message.sent` | Outgoing message is sent | | `message.edited` | The user edits a previously-sent message (also fires for `edited_channel_post` in channels where the bot is admin) | | `reaction.received` | A participant adds or removes an emoji reaction. The bot must be an administrator in the chat; reactions in private chats are not delivered | Messages are stored locally via webhooks. See the [Webhooks](/webhooks) page for payload details. **Note:** Telegram's Bot API does not expose deletion or read receipt events for regular bot chats. Delivery and read tracking is available only through the separate Telegram Business integration, which Zernio does not currently use. ### Notes - **Bot-based** - Uses bot tokens, not OAuth - Messages are stored locally when received via webhooks - Incoming callback data from inline keyboard taps is available in message `metadata.callbackData` See [Messages API Reference](/messages/list-inbox-conversations) for endpoint details. ## Related Endpoints - [Connect Telegram Account](/guides/connecting-accounts) - Access code connection flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Messages](/messages/list-inbox-conversations) - Inbox conversations and DMs - [Account Settings](/account-settings/get-telegram-commands) - Bot commands configuration --- # Threads Schedule and automate Threads posts with Zernio API - Text, images, videos, carousels, and thread sequences import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 500 | | Images per post | 10 (carousel) | | Videos per post | 1 | | Image formats | JPEG, PNG, WebP, GIF | | Image max size | 8 MB (auto-compressed) | | Video formats | MP4, MOV | | Video max size | 1 GB | | Video max duration | 5 minutes | | Post types | Text, Image, Video, Carousel, Thread sequence | | Scheduling | Yes | | Inbox (Comments) | Reply + delete + hide only | | Inbox (DMs) | No | | Analytics | Limited | ## Before You Start Threads has a **500 character limit**. This is the #1 failure -- users cross-posting from LinkedIn (3,000), Facebook (63,000), or even Instagram (2,200) captions will fail. Use `customContent` to provide a shorter Threads version. Threads is connected to your Instagram account. You **must** have an Instagram Business or Creator account with Threads enabled. Losing Instagram access means losing Threads. Additional requirements: - Connected via Instagram authentication (same Facebook app) - If your Instagram account is restricted, Threads posting fails too - Rate limit: 250 API-published posts per 24-hour window ## Quick Start Post to Threads in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Sharing some thoughts on building in public', platforms: [ { platform: 'threads', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Threads!', post._id); ``` ```python result = client.posts.create_post( content="Sharing some thoughts on building in public", platforms=[ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Threads! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Sharing some thoughts on building in public", "platforms": [ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text Post Threads is one of the few platforms that supports text-only posts -- no media required. Up to 500 characters. ```typescript const { post } = await zernio.posts.createPost({ content: 'Hot take: the best API is the one with the best docs.', platforms: [ { platform: 'threads', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Text post created!', post._id); ``` ```python result = client.posts.create_post( content="Hot take: the best API is the one with the best docs.", platforms=[ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Text post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hot take: the best API is the one with the best docs.", "platforms": [ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Image Post A single image with an optional caption. ```typescript const { post } = await zernio.posts.createPost({ content: 'New office setup is looking great', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/office.jpg' } ], platforms: [ { platform: 'threads', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Image post created!', post._id); ``` ```python result = client.posts.create_post( content="New office setup is looking great", media_items=[ {"type": "image", "url": "https://cdn.example.com/office.jpg"} ], platforms=[ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Image post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New office setup is looking great", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/office.jpg"} ], "platforms": [ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Video Post A single video with an optional caption. Max 1 GB, up to 5 minutes long. ```typescript const { post } = await zernio.posts.createPost({ content: 'Behind the scenes of our latest product launch', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/launch.mp4' } ], platforms: [ { platform: 'threads', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Video post created!', post._id); ``` ```python result = client.posts.create_post( content="Behind the scenes of our latest product launch", media_items=[ {"type": "video", "url": "https://cdn.example.com/launch.mp4"} ], platforms=[ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Video post created! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes of our latest product launch", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/launch.mp4"} ], "platforms": [ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Carousel Up to 10 images in a single swipeable post. ```typescript const { post } = await zernio.posts.createPost({ content: 'Product launch day! Here are all the new features:', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/feature1.jpg' }, { type: 'image', url: 'https://cdn.example.com/feature2.jpg' }, { type: 'image', url: 'https://cdn.example.com/feature3.jpg' } ], platforms: [ { platform: 'threads', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Carousel posted!', post._id); ``` ```python result = client.posts.create_post( content="Product launch day! Here are all the new features:", media_items=[ {"type": "image", "url": "https://cdn.example.com/feature1.jpg"}, {"type": "image", "url": "https://cdn.example.com/feature2.jpg"}, {"type": "image", "url": "https://cdn.example.com/feature3.jpg"} ], platforms=[ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Carousel posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Product launch day! Here are all the new features:", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/feature1.jpg"}, {"type": "image", "url": "https://cdn.example.com/feature2.jpg"}, {"type": "image", "url": "https://cdn.example.com/feature3.jpg"} ], "platforms": [ {"platform": "threads", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Thread Sequence Create connected posts (root + replies) using `threadItems`. The first item is the root post and subsequent items become replies in order. Each item can have its own text and media. > **Note:** 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]`. ```typescript const { post } = await zernio.posts.createPost({ platforms: [{ platform: 'threads', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { threadItems: [ { content: 'Here is a thread about API design', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/cover.jpg' }] }, { content: '1/ First, let us talk about REST principles...' }, { content: '2/ Authentication is crucial. Here is what we recommend...', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/auth-diagram.jpg' }] }, { content: '3/ Finally, always version your API! /end' } ] } }], publishNow: true }); console.log('Thread posted!', post._id); ``` ```python result = client.posts.create_post( platforms=[{ "platform": "threads", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "Here is a thread about API design", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/cover.jpg"}] }, {"content": "1/ First, let us talk about REST principles..."}, { "content": "2/ Authentication is crucial. Here is what we recommend...", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/auth-diagram.jpg"}] }, {"content": "3/ Finally, always version your API! /end"} ] } }], publish_now=True ) post = result.post print(f"Thread posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platforms": [{ "platform": "threads", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "Here is a thread about API design", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/cover.jpg"}] }, { "content": "1/ First, let us talk about REST principles..." }, { "content": "2/ Authentication is crucial. Here is what we recommend...", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/auth-diagram.jpg"}] }, { "content": "3/ Finally, always version your API! /end" } ] } }], "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 10 per post (carousel) | | **Formats** | JPEG, PNG, WebP, GIF | | **Max file size** | 8 MB per image (auto-compressed) | | **Recommended** | 1080 x 1350 px (4:5 portrait) | #### Aspect Ratios | Ratio | Dimensions | Notes | |-------|------------|-------| | 4:5 | 1080 x 1350 px | Portrait, recommended | | 1:1 | 1080 x 1080 px | Square | | 16:9 | 1080 x 608 px | Landscape | ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per post | | **Formats** | MP4, MOV | | **Max file size** | 1 GB | | **Max duration** | 5 minutes | | **Aspect ratio** | 9:16 (vertical), 16:9 (landscape), 1:1 (square) | | **Resolution** | 1080p recommended | | **Codec** | H.264 | | **Frame rate** | 30 fps recommended | | **Audio** | AAC, 128 kbps | ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Threads platform entry. | Field | Type | Description | |-------|------|-------------| | `topic_tag` | string | Topic tag for categorization/discoverability. 1-50 characters, cannot contain periods (`.`) or ampersands (`&`). When provided, overrides auto-extraction from content hashtags. | | `threadItems` | Array\<\{content, mediaItems?\}\> | Complete sequence of posts in a Threads thread. The first item becomes the root post and must be provided as `threadItems[0]`. When `threadItems` is provided, top-level `content` is for display/search only and is NOT published. | ## Topic Tags Use `platformSpecificData.topic_tag` to explicitly set the Threads topic tag for a post. > **Note:** If you set `topic_tag`, it overrides any topic tag that Threads might auto-extract from hashtags in your content. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Building better APIs starts with better docs.", "platforms": [{ "platform": "threads", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topic_tag": "APIDesign" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Building better APIs starts with better docs.', platforms: [{ platform: 'threads', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { topic_tag: 'APIDesign' } }], publishNow: true }); console.log('Posted to Threads!', post._id); ``` ```python result = client.posts.create_post( content="Building better APIs starts with better docs.", platforms=[{ "platform": "threads", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topic_tag": "APIDesign" } }], publish_now=True ) post = result.post print(f"Posted to Threads! {post['_id']}") ``` ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, it will not work. Threads uses the same media infrastructure as Instagram. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages **WebP images may fail** -- use JPEG or PNG for the most reliable results. Images above 8 MB are auto-compressed. Original files are preserved. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Impressions | ✅ | | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Views | ✅ | ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'threads', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="threads", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=threads&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through the Threads API: - Create polls - Use GIF search - Edit posts after publishing - See who liked or reposted - Create quote posts - Post new top-level comments (reply-only) - Like or unlike comments - Send DMs ## Common Errors Threads has a **14.5% failure rate** across Zernio's platform (7,705 failures out of 53,014 attempts). Here are the most frequent errors and how to fix them: | Error | Meaning | Fix | |-------|---------|-----| | "Param text must be at most 500 characters long." | Post exceeds the 500 character limit | Shorten to 500 characters. Use `customContent` for cross-platform posts so each platform gets its own version. | | "Media download has failed. The media URI doesn't meet our requirements." (2207052) | Threads cannot fetch media from the provided URL | URL must return actual media bytes, not an HTML page. WebP may fail -- use JPEG or PNG instead. | | "Instagram account is restricted." (2207050) | The linked Instagram account is restricted | Check your account status on Instagram. Resolve any policy violations before retrying. | | "Publishing failed due to max retries reached" | All publishing retries were exhausted | This is usually temporary. Wait a few minutes and retry manually. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Threads supports comment management through the unified Inbox API. Threads does not have direct messaging. ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Post new comment | ❌ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like/unlike comments | ❌ | | Hide/unhide comments | ✅ | ### Limitations - **No DMs** - Threads API does not support direct messages - **No comment likes** - Threads API does not support liking comments - **Reply-only** - Cannot post new top-level comments, only replies See [Comments API Reference](/comments/list-inbox-comments) for endpoint details. ## Related Endpoints - [Connect Threads Account](/guides/connecting-accounts) - Via Instagram authentication - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Performance metrics - [Comments](/comments/list-inbox-comments) - Reply to comments --- # TikTok Ads Create campaigns and Spark Ads via Zernio API - No TikTok Business Center developer onboarding required import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; **Included with the [Usage plan](/pricing).** No TikTok Business Center developer onboarding needed. Connect via OAuth and start building. ## What's Supported | Feature | Status | |---------|--------| | Standalone campaigns (Campaign > Ad Group > Ad) | Yes | | Website conversion ads (TikTok Pixel optimization) | Yes | | Spark Ads (boost organic videos) | Yes | | Spark Ad custom destination URL + CTA | Yes | | Spark Code (cross-creator boosts via `auth_code`) | Yes | | Bid strategy (Cost Cap, ROAS floor) | Yes | | Campaign duplication (manual graph copy) | Yes | | Attach-to-existing-adset on `/v1/ads/create` | Yes | | Creative swap on `PUT /v1/ads/{adId}` | Yes | | Targeting updates after creation | Yes | | Agency Business Centers (multi-advertiser + BC list endpoint) | Yes | | Custom Audiences (customer list) | Yes | | Age, gender, location, interest targeting | Yes | | Video creative from URL | Yes | | Real-time analytics (spend, views, CTR, CPM) | Yes | | Catalog / TikTok Shop ads | Roadmap | | Chunked video upload + async transcode | Roadmap | ## Create a Spark Ad (Boost) ```typescript const ad = await zernio.ads.boostPost({ body: { postId: "POST_ID", accountId: "ACCOUNT_ID", adAccountId: "7123456789012345678", name: "Boost viral video", goal: "traffic", budget: { amount: 50, type: "daily" }, schedule: { startDate: "2026-04-20", endDate: "2026-04-27" }, // TikTok-only Spark Ad creative overrides. Required for traffic / // conversion campaigns — without linkUrl the Spark Ad has no clickable // destination. CTA is the button label (e.g. SHOP_NOW, LEARN_MORE). linkUrl: "https://example.com/landing", callToAction: "SHOP_NOW", // Optional: Cost Cap bidding. bidStrategy is the cross-platform Meta enum; // we map it to TikTok's bid_type / bid_price automatically. bidStrategy: "COST_CAP", bidAmount: 0.50, }}); ``` ```python ad = client.ads.boost_post( post_id="POST_ID", account_id="ACCOUNT_ID", ad_account_id="7123456789012345678", name="Boost viral video", goal="traffic", budget={"amount": 50, "type": "daily"}, schedule={"startDate": "2026-04-20", "endDate": "2026-04-27"}, link_url="https://example.com/landing", call_to_action="SHOP_NOW", bid_strategy="COST_CAP", bid_amount=0.50, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/boost" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "postId": "POST_ID", "accountId": "ACCOUNT_ID", "adAccountId": "7123456789012345678", "name": "Boost viral video", "goal": "traffic", "budget": { "amount": 50, "type": "daily" }, "schedule": { "startDate": "2026-04-20", "endDate": "2026-04-27" }, "linkUrl": "https://example.com/landing", "callToAction": "SHOP_NOW", "bidStrategy": "COST_CAP", "bidAmount": 0.50 }' ``` Spark Ads retain the creator's identity, organic engagement signals, and follower handle. `linkUrl` and `callToAction` map to `landing_page_url` and `call_to_action` on TikTok's `/v2/ad/create/` creative — pass them when boosting for traffic/conversion goals so the ad has a clickable destination distinct from the original organic post. ## Create a Standalone Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_tiktokads_123", adAccountId: "7123456789012345678", name: "Spring launch", goal: "traffic", budgetAmount: 100, budgetType: "daily", body: "Spring drop is live", linkUrl: "https://example.com/spring", imageUrl: "https://cdn.example.com/launch.mp4", callToAction: "SHOP_NOW", countries: ["US"], ageMin: 18, ageMax: 34, }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_tiktokads_123", ad_account_id="7123456789012345678", name="Spring launch", goal="traffic", budget_amount=100, budget_type="daily", body="Spring drop is live", link_url="https://example.com/spring", image_url="https://cdn.example.com/launch.mp4", call_to_action="SHOP_NOW", countries=["US"], age_min=18, age_max=34, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_tiktokads_123", "adAccountId": "7123456789012345678", "name": "Spring launch", "goal": "traffic", "budgetAmount": 100, "budgetType": "daily", "body": "Spring drop is live", "linkUrl": "https://example.com/spring", "imageUrl": "https://cdn.example.com/launch.mp4", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 18, "ageMax": 34 }' ``` TikTok's ads endpoint is **video-only**. Pass the video URL as `imageUrl`, the field name is kept for cross-platform consistency. `headline` is ignored on TikTok (no headline slot in TikTok creatives); `body` is the video caption. `callToAction` is supported and maps to the in-feed CTA button. Valid `goal` values: `engagement`, `traffic`, `awareness`, `video_views`, `lead_generation`, `conversions`, `app_promotion`. For `goal: "conversions"` you must also pass `promotedObject.pixelId` (see [Conversion campaigns](#conversion-campaigns) below). ## Conversion campaigns A TikTok website-conversion campaign (`goal: "conversions"`, which Zernio maps to a `CONVERSIONS` objective with a `WEBSITE` / `CONVERT` ad group) needs a TikTok Pixel. Without one, TikTok rejects the ad group with `40002: Please select a pixel`. Pass the pixel via `promotedObject` (the same cross-platform field Meta uses): | `promotedObject` field | Maps to TikTok | Required | |---|---|---| | `pixelId` | ad group `pixel_id` (**numeric** — see note) | **Yes** | | `customEventType` | ad group `optimization_event` (the pixel event to optimise for) | No (auto-bid CONVERT works without it) | `customEventType` takes a TikTok `optimization_event` code (TikTok's own UPPER_SNAKE codes, **not** Meta's `PURCHASE`/`LEAD` vocabulary and **not** PascalCase). The value must be one of the events your pixel is configured to track. Common website codes: | Code | Pixel event | |---|---| | `ON_WEB_ORDER` | Complete Payment | | `INITIATE_ORDER` | Place an Order | | `ON_WEB_CART` | Add to Cart | | `ON_WEB_REGISTER` | Complete Registration | | `ON_WEB_DETAIL` | View Content | | `FORM` | Submit Form | | `ON_WEB_SEARCH` | Search | | `ON_WEB_ADD_TO_WISHLIST` | Add to Wishlist | | `LANDING_PAGE_VIEW` | Landing Page View | `pixelId` must be the **numeric** TikTok Pixel ID, not the alphanumeric Pixel Code shown in Events Manager (e.g. `D00IKHRC77UE0J0RTNHG`). TikTok's ad-group API rejects the alphanumeric code with `40002: pixel_id ... is not a valid integer string`. The numeric ID is the value `/pixel/list/` returns as `pixel_id`; if TikTok Ads Manager only surfaces the alphanumeric code for your pixel, retrieve the numeric ID via the TikTok Marketing API (`GET /pixel/list/`, filter by `code`). Find your pixel and the events it tracks in TikTok Ads Manager → Assets → Events. ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_tiktokads_123", adAccountId: "7123456789012345678", name: "Spring launch - purchases", goal: "conversions", budgetAmount: 100, budgetType: "daily", body: "Spring drop is live", linkUrl: "https://example.com/spring", imageUrl: "https://cdn.example.com/launch.mp4", callToAction: "SHOP_NOW", countries: ["US"], ageMin: 18, ageMax: 34, // Required for goal: "conversions" on TikTok. customEventType is optional. promotedObject: { pixelId: "7987654321098765432", customEventType: "ON_WEB_ORDER" }, }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_tiktokads_123", ad_account_id="7123456789012345678", name="Spring launch - purchases", goal="conversions", budget_amount=100, budget_type="daily", body="Spring drop is live", link_url="https://example.com/spring", image_url="https://cdn.example.com/launch.mp4", call_to_action="SHOP_NOW", countries=["US"], age_min=18, age_max=34, promoted_object={"pixelId": "7987654321098765432", "customEventType": "ON_WEB_ORDER"}, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_tiktokads_123", "adAccountId": "7123456789012345678", "name": "Spring launch - purchases", "goal": "conversions", "budgetAmount": 100, "budgetType": "daily", "body": "Spring drop is live", "linkUrl": "https://example.com/spring", "imageUrl": "https://cdn.example.com/launch.mp4", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 18, "ageMax": 34, "promotedObject": { "pixelId": "7987654321098765432", "customEventType": "ON_WEB_ORDER" } }' ``` Already set up a conversion ad group in TikTok Ads Manager (with the pixel + event configured)? Pass its ID as `adSetId` on `POST /v1/ads/create` instead. The new creative inherits the pixel and `optimization_event` from the parent ad group, so `promotedObject` isn't needed. See [Attach to existing ad group](#attach-to-existing-ad-group). ## Custom Audiences Create a customer-file Custom Audience and upload members. `adAccountId` is the TikTok advertiser ID. Email only (any `phone` is ignored); values are SHA256-hashed server-side. TikTok needs the member file at creation time, so the audience is **created lazily on the first member upload**: `POST /v1/ads/audiences` records it with status `pending` (no platform ID yet), and the first `users` call provisions it on TikTok. Newly created audiences take up to 48 hours to finish processing before they're targetable. ```typescript // 1. Register the audience (status: pending until the first upload) await zernio.adaudiences.createAdAudience({ body: { accountId: 'acc_tiktokads_123', adAccountId: '7330955083452284929', // TikTok advertiser ID type: 'customer_list', name: 'Cart abandoners', }, }); // 2. Upload members — this provisions the audience on TikTok await zernio.adaudiences.addUsersToAdAudience({ path: { audienceId: 'aud_abc123' }, // id from the create response body: { users: [{ email: 'jane@example.com' }, { email: 'sam@example.com' }] }, }); ``` ```python # 1. Register the audience (status: pending until the first upload) client.ad_audiences.create_ad_audience( account_id="acc_tiktokads_123", ad_account_id="7330955083452284929", # TikTok advertiser ID type="customer_list", name="Cart abandoners", ) # 2. Upload members — this provisions the audience on TikTok client.ad_audiences.add_users_to_ad_audience( audience_id="aud_abc123", # id from the create response users=[{"email": "jane@example.com"}, {"email": "sam@example.com"}], ) ``` ```bash # 1. Register the audience (status: pending) curl -X POST "https://zernio.com/api/v1/ads/audiences" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "accountId": "acc_tiktokads_123", "adAccountId": "7330955083452284929", "type": "customer_list", "name": "Cart abandoners" }' # 2. Upload members — provisions the audience on TikTok (use the id from the create response) curl -X POST "https://zernio.com/api/v1/ads/audiences/AUDIENCE_ID/users" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Video | MP4, MOV, MPEG | 500 MB | 9:16 vertical, 720p+, 5-60 sec | | Image | JPEG, PNG | 30 MB | 1080x1920 for full-screen | ## Agency Business Centers Connecting a TikTok account that owns one or more Business Centers automatically enumerates **every advertiser** under those BCs. There's no per-call cap — `GET /v1/ads/accounts` walks `/v2/bc/asset/get/` (paginated server-side) and returns the full roster. Solo advertisers without a BC fall back to the OAuth-time advertiser list (a single token can typically reach a handful of advertisers without BC). The advertiser list is cached on your connection for **1 hour** and lazy-refreshed on the next call after expiry. If you previously hit `502 Bad Gateway` with a "advertiser_ids: Length must be between 1 and 100" message on agency tokens, that's resolved — the lookup now chunks `/v2/advertiser/info/` calls underneath. ## Bid Strategy Pass `bidStrategy` (cross-platform Meta enum) on `POST /v1/ads/boost`, `POST /v1/ads/create`, or `PUT /v1/ads/ad-sets/{adSetId}`. Zernio maps it to TikTok's `bid_type` / `bid_price` / `deep_bid_type` automatically: | `bidStrategy` | Maps to TikTok | Required field | |---|---|---| | `LOWEST_COST_WITHOUT_CAP` (default) | `bid_type: BID_TYPE_NO_BID` | — | | `LOWEST_COST_WITH_BID_CAP` | `bid_type: BID_TYPE_CUSTOM` + `bid_price` | `bidAmount` | | `COST_CAP` | `bid_type: BID_TYPE_CUSTOM` + `bid_price` | `bidAmount` | | `LOWEST_COST_WITH_MIN_ROAS` | `bid_type: BID_TYPE_NO_BID` + `deep_bid_type: MIN_ROAS` | `roasAverageFloor` (account must be value-optimization-enabled) | `bidAmount` is in whole currency units of the ad account (USD: `5` = $5.00). On reads (`GET /v1/ads/tree`, `GET /v1/ads/campaigns`), TikTok's native `bid_type` is normalized back to the same Meta enum so cross-platform consumers see one shape regardless of source platform. ## Spark Code (cross-creator Spark Ads) To boost a creator's organic video from a different TikTok account than the one running the ads, the creator generates a Spark Code in their TikTok app's Promote settings and shares it with the advertiser. Pass it on `POST /v1/ads/boost`: ```typescript const ad = await zernio.ads.boostPost({ body: { platformPostId: 'CREATORS_VIDEO_ID', accountId: 'ACCOUNT_ID', adAccountId: '7123456789012345678', name: 'Cross-creator boost', goal: 'traffic', budget: { amount: 50, type: 'daily' }, linkUrl: 'https://example.com', callToAction: 'SHOP_NOW', sparkAuthCode: 'BCAQAAAA...', }, }); ``` ```python ad = client.ads.boost_post( platform_post_id="CREATORS_VIDEO_ID", account_id="ACCOUNT_ID", ad_account_id="7123456789012345678", name="Cross-creator boost", goal="traffic", budget={"amount": 50, "type": "daily"}, link_url="https://example.com", call_to_action="SHOP_NOW", spark_auth_code="BCAQAAAA...", ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/boost" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platformPostId": "CREATORS_VIDEO_ID", "accountId": "ACCOUNT_ID", "adAccountId": "7123456789012345678", "name": "Cross-creator boost", "goal": "traffic", "budget": { "amount": 50, "type": "daily" }, "linkUrl": "https://example.com", "callToAction": "SHOP_NOW", "sparkAuthCode": "BCAQAAAA..." }' ``` Without `sparkAuthCode`, boosts are limited to videos owned by the same TikTok account that's running the ads. Maps to `auth_code` on TikTok's `AdcreateCreatives`. ## Attach to existing ad group `POST /v1/ads/create` with `adSetId` set creates a new ad inside an existing TikTok ad group, skipping the campaign + ad-group create. Bid strategy, budget, targeting, and goal are inherited from the existing ad group. Use this when you've configured one ad group manually in TikTok Ads Manager and just want to add Zernio-managed creatives to it. ## Creative swap `PUT /v1/ads/{adId}` accepts a `creative` object to replace the live creative on an existing TikTok ad. Patch-style — only fields you supply are touched. `headline` is ignored (no slot on TikTok); `body` becomes `ad_text`; `linkUrl` becomes `landing_page_url`; `videoUrl` triggers a fresh upload. ## Targeting update after creation `PUT /v1/ads/{adId}` accepts `targeting` for TikTok ads — Zernio forwards it to `/v2/adgroup/update/` with the same field set as create. (Pinterest / X / LinkedIn / Google return 501; those platforms force re-creation for targeting changes.) ## Campaign duplication `POST /v1/ads/campaigns/{campaignId}/duplicate` works on TikTok via a manual graph walk: Zernio reads the source campaign + ad groups + ads, then recreates each entity with their bid configuration, targeting, schedule, and creative fields preserved. Spark Ad linkage (`tiktok_item_id`) carries over. Everything is created paused so you can review before launching. --- # TikTok Schedule and automate TikTok posts with Zernio API - Videos, photo carousels, privacy settings, and AI disclosure import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Character limit | 2,200 (video caption), 4,000 (photo desc) | | Photo title | 90 chars (auto-truncated, hashtags stripped) | | Photos per post | 35 (carousel) | | Videos per post | 1 | | Photo formats | JPEG, PNG, WebP | | Photo max size | 20 MB | | Video formats | MP4, MOV, WebM | | Video max size | 4 GB | | Video duration | 3 sec - 10 min | | Post types | Video, Photo Carousel | | Scheduling | Yes | | Inbox (Comments) | No | | Inbox (DMs) | No | | Analytics | Limited | ## Before You Start TikTok has a strict daily posting limit for posts created via third-party APIs. This limit is separate from the native app and is account-specific. When you hit it, the only options are to wait or post directly in TikTok. Also: the privacy levels available via API depend on each creator's TikTok account settings. You must fetch the creator's allowed levels and only use those, or the post will fail. Additional requirements: - Each creator has account-specific privacy level options - Content moderation is more aggressive via API than native app - All posts require consent flags (legal requirement from TikTok) - No text-only posts (media required) ## Quick Start Post a video to TikTok in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this amazing sunset! #sunset #nature', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/sunset-video.mp4' } ], platforms: [ { platform: 'tiktok', accountId: 'YOUR_ACCOUNT_ID' } ], tiktokSettings: { privacy_level: 'PUBLIC_TO_EVERYONE', allow_comment: true, allow_duet: true, allow_stitch: true, content_preview_confirmed: true, express_consent_given: true }, publishNow: true }); console.log('Posted to TikTok!', post._id); ``` ```python result = client.posts.create_post( content="Check out this amazing sunset! #sunset #nature", media_items=[ {"type": "video", "url": "https://cdn.example.com/sunset-video.mp4"} ], platforms=[ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], tiktok_settings={ "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": True, "allow_duet": True, "allow_stitch": True, "content_preview_confirmed": True, "express_consent_given": True }, publish_now=True ) post = result.post print(f"Posted to TikTok! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this amazing sunset! #sunset #nature", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/sunset-video.mp4"} ], "platforms": [ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": true, "allow_stitch": true, "content_preview_confirmed": true, "express_consent_given": true }, "publishNow": true }' ``` ## Content Types ### Video Post A single video post. Videos must be between 3 seconds and 10 minutes long. Vertical 9:16 aspect ratio is the only format that works well on TikTok. ```typescript const { post } = await zernio.posts.createPost({ content: 'New cooking tutorial #recipe #foodtok', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/cooking-tutorial.mp4' } ], platforms: [ { platform: 'tiktok', accountId: 'YOUR_ACCOUNT_ID' } ], tiktokSettings: { privacy_level: 'PUBLIC_TO_EVERYONE', allow_comment: true, allow_duet: true, allow_stitch: true, video_cover_timestamp_ms: 3000, content_preview_confirmed: true, express_consent_given: true }, publishNow: true }); console.log('Video posted!', post._id); ``` ```python result = client.posts.create_post( content="New cooking tutorial #recipe #foodtok", media_items=[ {"type": "video", "url": "https://cdn.example.com/cooking-tutorial.mp4"} ], platforms=[ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], tiktok_settings={ "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": True, "allow_duet": True, "allow_stitch": True, "video_cover_timestamp_ms": 3000, "content_preview_confirmed": True, "express_consent_given": True }, publish_now=True ) post = result.post print(f"Video posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New cooking tutorial #recipe #foodtok", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/cooking-tutorial.mp4"} ], "platforms": [ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": true, "allow_stitch": true, "video_cover_timestamp_ms": 3000, "content_preview_confirmed": true, "express_consent_given": true }, "publishNow": true }' ``` ### Custom Video Thumbnail For video posts, you can set a custom cover image using `video_cover_image_url`. When provided, it overrides `video_cover_timestamp_ms`. ```typescript const { post } = await zernio.posts.createPost({ content: 'New product teaser #launch', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/teaser.mp4' } ], platforms: [ { platform: 'tiktok', accountId: 'YOUR_ACCOUNT_ID' } ], tiktokSettings: { privacy_level: 'PUBLIC_TO_EVERYONE', allow_comment: true, allow_duet: true, allow_stitch: true, video_cover_image_url: 'https://cdn.example.com/teaser-cover.jpg', content_preview_confirmed: true, express_consent_given: true }, publishNow: true }); console.log('Video posted!', post._id); ``` ```python result = client.posts.create_post( content="New product teaser #launch", media_items=[ {"type": "video", "url": "https://cdn.example.com/teaser.mp4"} ], platforms=[ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], tiktok_settings={ "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": True, "allow_duet": True, "allow_stitch": True, "video_cover_image_url": "https://cdn.example.com/teaser-cover.jpg", "content_preview_confirmed": True, "express_consent_given": True }, publish_now=True ) post = result.post print(f"Video posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New product teaser #launch", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/teaser.mp4"} ], "platforms": [ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": true, "allow_stitch": true, "video_cover_image_url": "https://cdn.example.com/teaser-cover.jpg", "content_preview_confirmed": true, "express_consent_given": true }, "publishNow": true }' ``` ### Photo Carousel Up to 35 images in a single post. Photos are auto-resized to 1080x1920. The `content` field becomes the photo title (90 chars max, hashtags and URLs are auto-stripped). Use the `description` field inside `tiktokSettings` for a full caption up to 4,000 characters. ```typescript const { post } = await zernio.posts.createPost({ content: 'My travel highlights', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo1.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo2.jpg' }, { type: 'image', url: 'https://cdn.example.com/photo3.jpg' } ], platforms: [ { platform: 'tiktok', accountId: 'YOUR_ACCOUNT_ID' } ], tiktokSettings: { privacy_level: 'PUBLIC_TO_EVERYONE', allow_comment: true, media_type: 'photo', photo_cover_index: 0, description: 'Full trip recap from our weekend adventure across the coast. These are the best moments we captured along the way! #travel #roadtrip #adventure', auto_add_music: true, content_preview_confirmed: true, express_consent_given: true }, publishNow: true }); console.log('Photo carousel posted!', post._id); ``` ```python result = client.posts.create_post( content="My travel highlights", media_items=[ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], platforms=[ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], tiktok_settings={ "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": True, "media_type": "photo", "photo_cover_index": 0, "description": "Full trip recap from our weekend adventure across the coast. These are the best moments we captured along the way! #travel #roadtrip #adventure", "auto_add_music": True, "content_preview_confirmed": True, "express_consent_given": True }, publish_now=True ) post = result.post print(f"Photo carousel posted! {post['_id']}") ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "My travel highlights", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo1.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo2.jpg"}, {"type": "image", "url": "https://cdn.example.com/photo3.jpg"} ], "platforms": [ {"platform": "tiktok", "accountId": "YOUR_ACCOUNT_ID"} ], "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "media_type": "photo", "photo_cover_index": 0, "description": "Full trip recap from our weekend adventure across the coast. These are the best moments we captured along the way! #travel #roadtrip #adventure", "auto_add_music": true, "content_preview_confirmed": true, "express_consent_given": true }, "publishNow": true }' ``` ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max photos** | 35 per carousel | | **Formats** | JPEG, PNG, WebP | | **Max file size** | 20 MB per image | | **Aspect ratio** | 9:16 recommended | | **Resolution** | Auto-resized to 1080 x 1920 px | ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per post | | **Formats** | MP4, MOV, WebM | | **Max file size** | 4 GB | | **Max duration** | 10 minutes | | **Min duration** | 3 seconds | | **Aspect ratio** | 9:16 vertical (only format that works well) | | **Resolution** | 1080 x 1920 px recommended | | **Codec** | H.264 | | **Frame rate** | 30 fps recommended | You cannot mix photos and videos in the same post. Use either all photos (carousel) or one video. ## Platform-Specific Fields TikTok settings go in `tiktokSettings` at the **top level** of the request body, not inside `platformSpecificData`. This is a special case unique to TikTok. ## TikTok Creator Info Use this endpoint to fetch the creator's allowed `privacyLevels`, current `postingLimits` (including interaction defaults), and available `commercialContentTypes` before creating a post. > **Note:** This endpoint only works for TikTok accounts. If you pass a non-TikTok `accountId`, you'll get a 400 error. ```bash curl "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/tiktok/creator-info?mediaType=video" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```typescript const info = await zernio.accounts.getTikTokCreatorInfo({ accountId: 'YOUR_ACCOUNT_ID', mediaType: 'video' }); console.log(info.creator); console.log(info.privacyLevels); console.log(info.postingLimits); console.log(info.commercialContentTypes); ``` ```python info = client.accounts.get_tik_tok_creator_info( account_id="YOUR_ACCOUNT_ID", media_type="video" ) print(info["creator"]) print(info["privacyLevels"]) print(info["postingLimits"]) print(info["commercialContentTypes"]) ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `privacy_level` | string | Yes | Must match creator's allowed values. Options: `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` | | `allow_comment` | boolean | Yes | Enable or disable comments on the post | | `allow_duet` | boolean | Yes (videos) | Enable or disable duets. Only applies to video posts. | | `allow_stitch` | boolean | Yes (videos) | Enable or disable stitches. Only applies to video posts. | | `content_preview_confirmed` | boolean | Yes | Must be `true`. Legal requirement from TikTok. | | `express_consent_given` | boolean | Yes | Must be `true`. Legal requirement from TikTok. | | `video_cover_timestamp_ms` | number | No | Thumbnail frame position in milliseconds. Default: `1000`. Ignored when `video_cover_image_url` is provided. | | `video_cover_image_url` | string | No | Custom thumbnail image URL (JPG, PNG, or WebP, max 20MB). Overrides `video_cover_timestamp_ms`. | | `media_type` | `"photo"` | No | Set to `"photo"` for photo carousels. | | `photo_cover_index` | number | No | Which image to use as cover (0-indexed). | | `description` | string | No | Long-form caption for photo carousels, up to 4,000 characters. | | `auto_add_music` | boolean | No | Let TikTok add recommended music. Photo carousels only. | | `video_made_with_ai` | boolean | No | AI-generated content disclosure flag. | | `draft` | boolean | No | Send to Creator Inbox for review instead of publishing. | | `commercialContentType` | string | No | `"none"`, `"brand_organic"`, or `"brand_content"`. | ### Photo Carousel Caption Behavior The `content` field and `description` field serve different purposes for photo carousels: - **`content`** (top-level) -- becomes the photo **title**. Limited to 90 characters. Hashtags and URLs are auto-stripped. - **`description`** (inside `tiktokSettings`) -- becomes the full **caption** shown below the carousel. Up to 4,000 characters. ## Media URL Requirements **These do not work as media URLs:** - **Google Drive** -- returns an HTML download page, not the file - **Dropbox** -- returns an HTML preview page - **OneDrive / SharePoint** -- returns HTML - **iCloud** -- returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw video or image, it will not work. Media URLs must be: - Publicly accessible (no authentication required) - Returning actual media bytes with the correct `Content-Type` header - Not behind redirects that resolve to HTML pages - Hosted on a fast, reliable CDN Large videos are auto-chunked during upload (5-64 MB per chunk). Photos are auto-resized to 1080x1920. ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Views | ✅ | TikTok also provides a dedicated [Account Insights API](/analytics/get-tiktok-account-insights) for account-level counters (follower_count, following_count, likes_count, video_count) plus Zernio-synthesized followers_gained and followers_lost deltas. Live values come from TikTok's user.info.stats scope; historical time series is joined from Zernio's daily snapshotter. Deep metrics from TikTok Studio (profile views, account-level impressions and reach, per-video watch time and average watch time, full-watched rate, impression sources like FYP / Following / Hashtag / Search) are not available on any TikTok public API. TikTok's Research API does not expose these either and is restricted to non-commercial academic use per TikTok's eligibility policy. There is no public API workaround. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'tiktok', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="tiktok", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=tiktok&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do These features are not available through TikTok's API: - Use TikTok's sound/music library (except `auto_add_music` for photo carousels) - Create duets or stitches - Go Live - Add effects or filters - Edit posts after publishing - View For You Page analytics - Create playlists - Read or write comments - Send or read DMs - Create text-only posts (media required) ## Common Errors TikTok has a **13.1% failure rate** across Zernio's platform (30,746 failures out of 235,045 attempts). Here are the most frequent errors and how to fix them: | Error | Meaning | Fix | |-------|---------|-----| | "You have created too many posts in the last 24 hours via the API." | TikTok's daily API posting limit hit | Wait until limit resets (24h rolling) or post directly in TikTok app. | | "Publishing failed during platform API call (timeout waiting for platform response)" | TikTok's servers took too long to process | For large videos this can be normal. Check post status after a few minutes. | | "Selected privacy level 'X' is not available for this creator. Available options: ..." | Privacy level does not match creator's account settings | Fetch creator info to get allowed privacy levels and use one of those. | | "TikTok flagged this post as potentially risky (spam_risk)" | Content moderation flagged the post | Review content. TikTok's API moderation is stricter than the native app. | | "Duplicate content detected." | Same content was already posted recently | Modify the caption or media before retrying. | | "TikTok video upload failed: Your video URL returned an error (download failed)" | TikTok could not download the video from the URL | Ensure the URL is a direct download link, not a cloud storage sharing page. | | "Missing required TikTok permissions. Please reconnect with all required scopes." | OAuth token is missing required scopes | Reconnect the TikTok account with all required permissions. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). TikTok has no inbox support. ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ❌ | | Post new comment | ❌ | | Reply to comments | ❌ | | Delete comments | ❌ | ### Limitations - **No comments support** - Zernio does not currently support TikTok comments - **No DMs** - Zernio does not currently support TikTok DMs ## Related Endpoints - [Connect TikTok Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image and video uploads - [Analytics](/analytics/get-analytics) - Performance metrics - [Comments](/comments/list-inbox-comments) - Comments API Reference --- # X Ads Create campaigns and promote tweets via Zernio API - OAuth 1.0a and approval handled for you import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; **Included with the [Usage plan](/pricing).** No X Ads API approval application needed. OAuth 1.0a request signing is handled server-side. **`accountId` accepts either shape.** Pass the posting X/Twitter account ID or the X Ads credential account ID, Zernio resolves the sibling internally so either value works. If no X Ads credential is connected for the resolved profile, the API returns `code: "ads_connection_required"`. ## What's Supported | Feature | Status | |---------|--------| | Standalone campaigns (Campaign > Line Item > Promoted Tweet) | Yes | | Promote existing tweets | Yes | | Location + language targeting | Yes | | Real-time analytics (spend, CPE, CPM, link clicks) | Yes | | OAuth 1.0a signing handled | Yes | | Keyword targeting | Roadmap | | Follower look-alike targeting | Roadmap | | Tailored Audiences (read, create, member upload) | Yes | ## Promote an Existing Tweet ```typescript const ad = await zernio.ads.boostPost({ body: { postId: "POST_ID", accountId: "ACCOUNT_ID", adAccountId: "18ce54d4x5t", name: "Boost launch tweet", goal: "engagement", budget: { amount: 50, type: "daily" }, schedule: { startDate: "2026-04-20", endDate: "2026-04-27" }, }}); ``` ```python ad = client.ads.boost_post( post_id="POST_ID", account_id="ACCOUNT_ID", ad_account_id="18ce54d4x5t", name="Boost launch tweet", goal="engagement", budget={"amount": 50, "type": "daily"}, schedule={"startDate": "2026-04-20", "endDate": "2026-04-27"}, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/boost" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "postId": "POST_ID", "accountId": "ACCOUNT_ID", "adAccountId": "18ce54d4x5t", "name": "Boost launch tweet", "goal": "engagement", "budget": { "amount": 50, "type": "daily" }, "schedule": { "startDate": "2026-04-20", "endDate": "2026-04-27" } }' ``` ## Create a Standalone Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: "acc_xads_123", adAccountId: "18ce54d4x5t", name: "Q2 Product Awareness", goal: "awareness", budgetAmount: 75, budgetType: "daily", body: "Ship faster with platform engineering done right.", linkUrl: "https://example.com/platform", countries: ["US"], }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_xads_123", ad_account_id="18ce54d4x5t", name="Q2 Product Awareness", goal="awareness", budget_amount=75, budget_type="daily", body="Ship faster with platform engineering done right.", link_url="https://example.com/platform", countries=["US"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_xads_123", "adAccountId": "18ce54d4x5t", "name": "Q2 Product Awareness", "goal": "awareness", "budgetAmount": 75, "budgetType": "daily", "body": "Ship faster with platform engineering done right.", "linkUrl": "https://example.com/platform", "countries": ["US"] }' ``` For X, `body` is the tweet text (max 280 chars; X shortens `linkUrl` to ~24 chars against that limit). `headline`, `imageUrl`, and `callToAction` are ignored on X. Valid `goal` values: `engagement`, `traffic`, `awareness`, `video_views`, `app_promotion`. ## OAuth 1.0a The X Ads API uses OAuth 1.0a request signing (not OAuth 2.0 Bearer tokens). Zernio computes HMAC-SHA1 signatures server-side, so your integration uses a standard Bearer token like every other platform. ## Tailored Audiences Create a Tailored (custom) Audience and upload members. `adAccountId` is the X Ads account ID. Email only (any `phone` is ignored); values are SHA256-hashed server-side. An audience must match at least 100 recently-active users before X will let you target it, so the size stays `0`/`-` until then. ```typescript // 1. Create the (empty) tailored audience await zernio.adaudiences.createAdAudience({ body: { accountId: 'acc_xads_123', adAccountId: '18ce55abc12', // X Ads account ID type: 'customer_list', name: 'Trial signups', }, }); // 2. Upload members to the returned audience id await zernio.adaudiences.addUsersToAdAudience({ path: { audienceId: 'aud_abc123' }, // id from the create response body: { users: [{ email: 'jane@example.com' }, { email: 'sam@example.com' }] }, }); ``` ```python # 1. Create the (empty) tailored audience client.ad_audiences.create_ad_audience( account_id="acc_xads_123", ad_account_id="18ce55abc12", # X Ads account ID type="customer_list", name="Trial signups", ) # 2. Upload members to the returned audience id client.ad_audiences.add_users_to_ad_audience( audience_id="aud_abc123", # id from the create response users=[{"email": "jane@example.com"}, {"email": "sam@example.com"}], ) ``` ```bash # 1. Create curl -X POST "https://zernio.com/api/v1/ads/audiences" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "accountId": "acc_xads_123", "adAccountId": "18ce55abc12", "type": "customer_list", "name": "Trial signups" }' # 2. Upload members (use the audience id from the create response) curl -X POST "https://zernio.com/api/v1/ads/audiences/AUDIENCE_ID/users" \ -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Image | JPEG, PNG, GIF, WebP | 5 MB | 1200x675 (16:9) recommended | | Video | MP4, MOV | 1 GB | 0.5-140 sec, H.264+AAC | | Tweet text | UTF-8 | 280 chars | Standard limit | | Website Card | URL + text | 70 char title | Landing page card | --- # YouTube Schedule and automate YouTube video uploads with Zernio API - Videos, Shorts, thumbnails, visibility, and COPPA settings import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Reference | Property | Value | |----------|-------| | Title limit | 100 characters | | Description limit | 5,000 characters | | Tags limit | 500 characters total (all tags combined) | | Videos per post | 1 | | Video formats | MP4, MOV, AVI, WMV, FLV, 3GP, WebM | | Video max size | 256 GB | | Video max duration | 15 min (unverified), 12 hours (verified) | | Thumbnail formats | JPEG, PNG, GIF | | Thumbnail max size | 2 MB | | Post types | Video, Shorts | | Scheduling | Yes (uploads as private, goes public at scheduled time) | | Inbox (Comments) | Yes | | Inbox (DMs) | No (YouTube has no DM system) | | Analytics | Yes | ## Before You Start YouTube is video-only. Every post requires exactly one video file. Unverified channels are limited to 15-minute videos -- verify your channel via phone number to unlock longer uploads. If a YouTube channel is suspended, ALL uploads fail with a 403 error. Use the account health endpoint to check status before scheduling posts. - One video per post (no image-only or text-only posts) - Unverified channels have a 15-minute maximum video duration - YouTube has daily upload quotas that vary by channel - Shorts are auto-detected from duration and aspect ratio (not a separate post type) ## Quick Start Upload a video to YouTube: ```typescript const { post } = await zernio.posts.createPost({ content: 'Video description here', mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [{ platform: 'youtube', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: 'My Video Title', visibility: 'public' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Video description here", media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public" } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Video description here", "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public" } }], "publishNow": true }' ``` ## Content Types ### Regular Videos Long-form content with a duration greater than 3 minutes or a horizontal aspect ratio. Regular videos support custom thumbnails and 16:9 is the recommended aspect ratio. ```typescript const { post } = await zernio.posts.createPost({ content: 'In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial', mediaItems: [{ type: 'video', url: 'https://example.com/long-form-video.mp4', thumbnail: 'https://example.com/thumbnail.jpg' }], platforms: [{ platform: 'youtube', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: 'Build a REST API from Scratch', visibility: 'public', categoryId: '27', madeForKids: false } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial", media_items=[{ "type": "video", "url": "https://example.com/long-form-video.mp4", "thumbnail": "https://example.com/thumbnail.jpg" }], platforms=[{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "Build a REST API from Scratch", "visibility": "public", "categoryId": "27", "madeForKids": False } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial", "mediaItems": [{ "type": "video", "url": "https://example.com/long-form-video.mp4", "thumbnail": "https://example.com/thumbnail.jpg" }], "platforms": [{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "Build a REST API from Scratch", "visibility": "public", "categoryId": "27", "madeForKids": false } }], "publishNow": true }' ``` ### YouTube Shorts YouTube automatically detects Shorts based on duration and aspect ratio. A video that is 3 minutes or shorter AND has a vertical (9:16) aspect ratio is classified as a Short. There is no separate post type or flag to set -- just upload a short vertical video and YouTube handles the rest. - Videos under 15 seconds loop automatically - Custom thumbnails are **not** supported for Shorts via the API - No code changes are needed compared to regular videos; the detection is entirely automatic ## Media Requirements ### Video Requirements | Property | Shorts | Regular Video | |----------|--------|---------------| | **Max Duration** | 3 minutes | 12 hours (verified), 15 min (unverified) | | **Min Duration** | 1 second | 1 second | | **Max File Size** | 256 GB | 256 GB | | **Formats** | MP4, MOV, AVI, WMV, FLV, 3GP, WebM | MP4, MOV, AVI, WMV, FLV, 3GP, WebM | | **Aspect Ratio** | 9:16 (vertical) | 16:9 (horizontal) | | **Resolution** | 1080 x 1920 px | 1920 x 1080 px (1080p) | ### Recommended Specs | Property | Shorts | Regular Video | |----------|--------|---------------| | Resolution | 1080 x 1920 px | 3840 x 2160 px (4K) | | Frame Rate | 30 fps | 24-60 fps | | Codec | H.264 | H.264 or H.265 | | Audio | AAC, 128 kbps | AAC, 384 kbps | | Bitrate | 10 Mbps | 35-68 Mbps (4K) | ### Custom Thumbnails Custom thumbnails are supported for regular videos only (not Shorts). | Property | Requirement | |----------|-------------| | Format | JPEG, PNG, GIF | | Max Size | 2 MB | | Recommended Resolution | 1280 x 720 px (16:9) | | Min Width | 640 px | ```typescript const { post } = await zernio.posts.createPost({ content: 'My Video Description', mediaItems: [{ type: 'video', url: 'https://example.com/video.mp4', thumbnail: 'https://example.com/thumbnail.jpg' }], platforms: [{ platform: 'youtube', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: 'My Video Title', visibility: 'public' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="My Video Description", media_items=[{ "type": "video", "url": "https://example.com/video.mp4", "thumbnail": "https://example.com/thumbnail.jpg" }], platforms=[{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public" } }], publish_now=True ) ``` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "My Video Description", "mediaItems": [{ "type": "video", "url": "https://example.com/video.mp4", "thumbnail": "https://example.com/thumbnail.jpg" }], "platforms": [{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public" } }], "publishNow": true }' ``` ## Platform-Specific Fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `title` | string | First line of `content`, or `"Untitled Video"` | Video title. Maximum 100 characters. | | `visibility` | `"public"` \| `"private"` \| `"unlisted"` | `"public"` | Controls who can see the video. | | `madeForKids` | boolean | `false` | COPPA compliance flag. Setting to `true` **permanently** disables comments, notification bell, personalized ads, end screens, and cards on the video. COPPA violations carry fines of $42,000 or more. | | `containsSyntheticMedia` | boolean | `false` | AI-generated content disclosure. YouTube is increasingly enforcing this requirement. | | `categoryId` | string | `"22"` (People & Blogs) | Video category. Common values: `"1"` Film, `"10"` Music, `"20"` Gaming, `"22"` People & Blogs, `"27"` Education, `"28"` Science & Technology. | | `playlistId` | string | -- | Optional YouTube playlist ID to add the video to after upload (e.g. `"PLxxxxxxxxxxxxx"`). Use `GET /v1/accounts/{accountId}/youtube-playlists` to list available playlists. | | `firstComment` | string | -- | Auto-posted and pinned comment. Maximum 10,000 characters. For `publishNow`: posted immediately. For scheduled posts: posted when the video goes live. | ## YouTube Playlists Use playlists to automatically add an uploaded video to a specific YouTube playlist. > **Note:** `playlistId` is optional. If omitted, the video is uploaded normally and is not added to any playlist. ### 1) List available playlists Fetch playlists for a connected YouTube account to get a `playlistId`. ```bash curl "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/youtube-playlists" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```typescript const result = await zernio.request({ method: 'GET', path: `/v1/accounts/${accountId}/youtube-playlists` }); console.log(result.playlists); ``` ```python result = client.request( method="GET", path=f"/v1/accounts/{account_id}/youtube-playlists" ) print(result["playlists"]) ``` ### 2) Create a post with `playlistId` ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Video description here", "mediaItems": [ {"type": "video", "url": "https://example.com/video.mp4"} ], "platforms": [{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public", "playlistId": "PLxxxxxxxxxxxxx" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Video description here', mediaItems: [ { type: 'video', url: 'https://example.com/video.mp4' } ], platforms: [{ platform: 'youtube', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { title: 'My Video Title', visibility: 'public', playlistId: 'PLxxxxxxxxxxxxx' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Video description here", media_items=[ {"type": "video", "url": "https://example.com/video.mp4"} ], platforms=[{ "platform": "youtube", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "title": "My Video Title", "visibility": "public", "playlistId": "PLxxxxxxxxxxxxx" } }], publish_now=True ) ``` ### 3) (Optional) Store a default playlist (client-side convenience) You can store a default playlist for an account to prefill UI selections. This does not automatically apply to posts that omit `playlistId`. ```bash curl -X PUT https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/youtube-playlists \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "defaultPlaylistId": "PLxxxxxxxxxxxxx", "defaultPlaylistName": "Tutorials" }' ``` ```typescript const result = await zernio.request({ method: 'PUT', path: `/v1/accounts/${accountId}/youtube-playlists`, body: { defaultPlaylistId: 'PLxxxxxxxxxxxxx', defaultPlaylistName: 'Tutorials' } }); console.log(result.success); ``` ```python result = client.request( method="PUT", path=f"/v1/accounts/{account_id}/youtube-playlists", body={ "defaultPlaylistId": "PLxxxxxxxxxxxxx", "defaultPlaylistName": "Tutorials" } ) print(result["success"]) ``` ### Scheduling Behavior When you schedule a YouTube video for a future time, the following sequence occurs: 1. The video uploads **immediately** as `"private"` regardless of your target visibility 2. A video URL exists right away, but the video is not publicly accessible 3. At the scheduled time, visibility changes to your target setting (usually `"public"`) 4. The `firstComment` is posted at the scheduled time, not at upload time ## Media URL Requirements - The URL must return actual video bytes (not an HTML page) - No authentication or expired links -- the URL must be publicly accessible - Large videos (1 GB or more) can take 30-60+ minutes to process on YouTube's side - During processing, the video shows a "processing" state -- do not retry the upload ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). Available metrics via the [Analytics API](/analytics/get-analytics): | Metric | Available | |--------|-----------| | Likes | ✅ | | Comments | ✅ | | Shares | ✅ (via Daily Views only) | | Views | ✅ | YouTube also provides a dedicated [Daily Views API](/analytics/get-youtube-daily-views) with detailed daily breakdowns including watch time, subscriber changes, and per-day likes/comments/shares. Data has a 2-3 day delay. For channel-level aggregates (views, watch time, average view duration, subscribers gained/lost) without looping through every video, see the [Channel Insights API](/analytics/get-youtube-channel-insights). Impressions and impressions click-through rate (the thumbnail metrics from YouTube Studio) are not exposed by YouTube's Analytics API v2 for any principal type; the only way to get those is manual Studio CSV export. The [Demographics API](/analytics/get-youtube-demographics) returns audience breakdowns by age, gender, and 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. ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'youtube', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="youtube", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=youtube&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## What You Can't Do - Create Community posts - Go Live or schedule Premieres - Add end screens, cards, or chapters (timestamps in the description do work) - Manage monetization settings - Create or delete playlists (you can list playlists and add videos to an existing playlist) - Like or dislike videos - Upload captions or subtitles ## Common Errors | Error | Meaning | Fix | |-------|---------|-----| | `"The YouTube account of the authenticated user is suspended."` (403) | YouTube channel is suspended by YouTube | Check channel status on YouTube. Use the account health endpoint. | | `"Social account not found"` | Connected account was disconnected or deleted from Zernio | Reconnect the YouTube account. Subscribe to the `account.disconnected` webhook. | | `"Account was deleted"` | User deleted the social account | Reconnect the account. | | `"Failed to fetch video from URL: 404"` | Video URL returned a 404 | Verify the URL is still valid and publicly accessible. Links expire on some hosting services. | | `"YouTube permission error: Ensure the channel has required scopes and features enabled."` | Missing OAuth scopes | Reconnect the YouTube account with all required permissions. | | `"YouTube upload initialization failed: 403"` | Upload rejected before file transfer began | Check whether the channel is suspended, the upload quota has been hit, or permissions are missing. | ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). YouTube supports comments only (no DMs available on the platform). ### Comments | Feature | Supported | |---------|-----------| | List comments on videos | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like comments | ❌ (no API available) | ### Limitations - **No DMs** - YouTube does not have a direct messaging system - **No comment likes** - No public API endpoint available for liking comments See [Comments API Reference](/comments/list-inbox-comments) for endpoint details. ## Related Endpoints - [Connect YouTube Account](/guides/connecting-accounts) - OAuth flow - [List YouTube Playlists](/connect/get-youtube-playlists) - Get playlist IDs for `playlistId` - [Set Default YouTube Playlist](/connect/update-youtube-default-playlist) - Store a default playlist selection - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Video uploads - [Analytics](/analytics/get-analytics) - Performance metrics - [YouTube Daily Views](/analytics/get-youtube-daily-views) - Daily view statistics - [YouTube Demographics](/analytics/get-youtube-demographics) - Audience age, gender, and country breakdowns - [Comments](/comments/list-inbox-comments) - Read and reply to comments --- # Open Source Open-source projects and OpenAPI specifications built with and for the Zernio API import { Cards, Card } from 'fumadocs-ui/components/card'; ## Projects } title="Latewiz" description="Your social media scheduling wizard - Open source scheduler powered by Zernio API" href="https://github.com/zernio-dev/latewiz" /> } title="Zernflow" description="Open-source ManyChat alternative. Visual chatbot builder for Instagram, Facebook, Telegram, Twitter/X, Bluesky & Reddit." href="https://github.com/zernio-dev/zernflow" /> ## OpenAPI Specs Free, downloadable OpenAPI specifications for all major social media platforms. Use these specs to generate SDKs, build API clients, or explore API capabilities in tools like Swagger UI or Postman. All specs are maintained in our [public GitHub repository](https://github.com/zernio-dev/openapi-specs). ### Platform API Specifications ### Direct Download Links | Platform | Download | Size | |----------|----------|------| | Twitter / X | [twitter.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/twitter.yaml) | 478 KB | | Pinterest | [pinterest.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/pinterest.yaml) | 1.5 MB | | Telegram | [telegram.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/telegram.yaml) | 111 KB | | YouTube | [youtube.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/youtube.yaml) | 96 KB | | Reddit | [reddit.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/reddit.yaml) | 71 KB | | Google Business | [googlebusiness.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/googlebusiness.yaml) | 59 KB | | LinkedIn | [linkedin.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/linkedin.yaml) | 54 KB | | Snapchat | [snapchat.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/snapchat.yaml) | 51 KB | | Threads | [threads.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/threads.yaml) | 50 KB | | Bluesky | [bluesky.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/bluesky.yaml) | 45 KB | | TikTok | [tiktok.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/tiktok.yaml) | 43 KB | | Instagram | [instagram.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/instagram.yaml) | 39 KB | | Facebook | [facebook.yaml](https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/facebook.yaml) | 38 KB | ### Using These Specs **Import into Postman:** 1. Copy the raw URL for your platform (e.g., `https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/twitter.yaml`) 2. Open Postman and go to **File → Import** 3. Paste the URL or download and select the file **Generate an SDK:** ```bash # Download the spec curl -O https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/twitter.yaml # Generate TypeScript SDK openapi-generator-cli generate -i twitter.yaml -g typescript-fetch -o ./twitter-sdk ``` **View in Swagger UI:** ```bash docker run -p 8080:8080 -e SWAGGER_JSON_URL=https://raw.githubusercontent.com/zernio-dev/openapi-specs/main/twitter.yaml swaggerapi/swagger-ui ``` Found an issue or want to improve a spec? Contributions are welcome on [GitHub](https://github.com/zernio-dev/openapi-specs). --- # Create group API Reference Creates a new account group with a name and a list of social account IDs. Accounts can belong to different profiles; the caller must have access to every account's profile. Group names must be unique per user. ## GET /v1/account-groups **List groups** Returns all account groups visible to the authenticated user. Groups can contain accounts from multiple profiles. For API keys scoped to specific profiles, only groups whose accounts all live in allowed profiles are returned. ### Responses #### 200: Groups **Response Body:** - **groups** `array[object]`: - **_id** `string`: No description - **name** `string`: No description - **accountIds** `array[string]`: - **createdBy** `string`: No description - **profileId** `string`: Legacy field. Present only on groups created before cross-profile groups were supported. New groups omit it. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/account-groups **Create group** Creates a new account group with a name and a list of social account IDs. Accounts can belong to different profiles; the caller must have access to every account's profile. Group names must be unique per user. ### Request Body - **name** (required) `string`: No description - **accountIds** (required) `array`: No description - **profileId** `string`: Deprecated. Accepted for backward compatibility but ignored. Groups are no longer scoped to a single profile. ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **group** `object`: - **_id** `string`: No description - **name** `string`: No description - **accountIds** `array[string]`: #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Group name already exists --- --- # Delete group API Reference Permanently deletes an account group. The accounts themselves are not affected. ## PUT /v1/account-groups/{groupId} **Update group** Updates the name or account list of an existing group. You can rename the group, change its accounts, or both. ### Parameters - **groupId** (required) in path: No description ### Request Body - **name** `string`: No description - **accountIds** `array`: No description ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **group** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 409: Group name already exists --- ## DELETE /v1/account-groups/{groupId} **Delete group** Permanently deletes an account group. The accounts themselves are not affected. ### Parameters - **groupId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List groups API Reference Returns all account groups visible to the authenticated user. Groups can contain accounts from multiple profiles. For API keys scoped to specific profiles, only groups whose accounts all live in allowed profiles are returned. ## GET /v1/account-groups **List groups** Returns all account groups visible to the authenticated user. Groups can contain accounts from multiple profiles. For API keys scoped to specific profiles, only groups whose accounts all live in allowed profiles are returned. ### Responses #### 200: Groups **Response Body:** - **groups** `array[object]`: - **_id** `string`: No description - **name** `string`: No description - **accountIds** `array[string]`: - **createdBy** `string`: No description - **profileId** `string`: Legacy field. Present only on groups created before cross-profile groups were supported. New groups omit it. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/account-groups **Create group** Creates a new account group with a name and a list of social account IDs. Accounts can belong to different profiles; the caller must have access to every account's profile. Group names must be unique per user. ### Request Body - **name** (required) `string`: No description - **accountIds** (required) `array`: No description - **profileId** `string`: Deprecated. Accepted for backward compatibility but ignored. Groups are no longer scoped to a single profile. ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **group** `object`: - **_id** `string`: No description - **name** `string`: No description - **accountIds** `array[string]`: #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Group name already exists --- --- # Update group API Reference Updates the name or account list of an existing group. You can rename the group, change its accounts, or both. ## PUT /v1/account-groups/{groupId} **Update group** Updates the name or account list of an existing group. You can rename the group, change its accounts, or both. ### Parameters - **groupId** (required) in path: No description ### Request Body - **name** `string`: No description - **accountIds** `array`: No description ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **group** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 409: Group name already exists --- ## DELETE /v1/account-groups/{groupId} **Delete group** Permanently deletes an account group. The accounts themselves are not affected. ### Parameters - **groupId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Delete IG ice breakers API Reference Removes the ice breaker questions from an Instagram account's Messenger experience. ## GET /v1/accounts/{accountId}/instagram-ice-breakers **Get IG ice breakers** Get the ice breaker configuration for an Instagram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breaker configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not an Instagram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/instagram-ice-breakers **Set IG ice breakers** Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. ### Parameters - **accountId** (required) in path: No description ### Request Body - **ice_breakers** (required) `array`: No description ### Responses #### 200: Ice breakers set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/instagram-ice-breakers **Delete IG ice breakers** Removes the ice breaker questions from an Instagram account's Messenger experience. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breakers deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Delete FB persistent menu API Reference Removes the persistent menu from Facebook Messenger conversations for this account. ## GET /v1/accounts/{accountId}/messenger-menu **Get FB persistent menu** Get the persistent menu configuration for a Facebook Messenger account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Persistent menu configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not a Facebook account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/messenger-menu **Set FB persistent menu** Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. ### Parameters - **accountId** (required) in path: No description ### Request Body - **persistent_menu** (required) `array`: Persistent menu configuration array (Meta format) ### Responses #### 200: Menu set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/messenger-menu **Delete FB persistent menu** Removes the persistent menu from Facebook Messenger conversations for this account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Menu deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Delete TG bot commands API Reference Clears all bot commands configured for a Telegram bot account. ## GET /v1/accounts/{accountId}/telegram-commands **Get TG bot commands** Get the bot commands configuration for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Bot commands list **Response Body:** - **data** `array[object]`: - **command** `string`: No description - **description** `string`: No description #### 400: Not a Telegram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/telegram-commands **Set TG bot commands** Set bot commands for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **commands** (required) `array`: No description ### Responses #### 200: Commands set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/telegram-commands **Delete TG bot commands** Clears all bot commands configured for a Telegram bot account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Commands deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Get IG ice breakers API Reference Get the ice breaker configuration for an Instagram account. ## GET /v1/accounts/{accountId}/instagram-ice-breakers **Get IG ice breakers** Get the ice breaker configuration for an Instagram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breaker configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not an Instagram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/instagram-ice-breakers **Set IG ice breakers** Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. ### Parameters - **accountId** (required) in path: No description ### Request Body - **ice_breakers** (required) `array`: No description ### Responses #### 200: Ice breakers set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/instagram-ice-breakers **Delete IG ice breakers** Removes the ice breaker questions from an Instagram account's Messenger experience. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breakers deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Get FB persistent menu API Reference Get the persistent menu configuration for a Facebook Messenger account. ## GET /v1/accounts/{accountId}/messenger-menu **Get FB persistent menu** Get the persistent menu configuration for a Facebook Messenger account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Persistent menu configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not a Facebook account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/messenger-menu **Set FB persistent menu** Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. ### Parameters - **accountId** (required) in path: No description ### Request Body - **persistent_menu** (required) `array`: Persistent menu configuration array (Meta format) ### Responses #### 200: Menu set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/messenger-menu **Delete FB persistent menu** Removes the persistent menu from Facebook Messenger conversations for this account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Menu deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Get TG bot commands API Reference Get the bot commands configuration for a Telegram account. ## GET /v1/accounts/{accountId}/telegram-commands **Get TG bot commands** Get the bot commands configuration for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Bot commands list **Response Body:** - **data** `array[object]`: - **command** `string`: No description - **description** `string`: No description #### 400: Not a Telegram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/telegram-commands **Set TG bot commands** Set bot commands for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **commands** (required) `array`: No description ### Responses #### 200: Commands set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/telegram-commands **Delete TG bot commands** Clears all bot commands configured for a Telegram bot account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Commands deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Set IG ice breakers API Reference Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. ## GET /v1/accounts/{accountId}/instagram-ice-breakers **Get IG ice breakers** Get the ice breaker configuration for an Instagram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breaker configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not an Instagram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/instagram-ice-breakers **Set IG ice breakers** Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. ### Parameters - **accountId** (required) in path: No description ### Request Body - **ice_breakers** (required) `array`: No description ### Responses #### 200: Ice breakers set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/instagram-ice-breakers **Delete IG ice breakers** Removes the ice breaker questions from an Instagram account's Messenger experience. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Ice breakers deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Set FB persistent menu API Reference Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. ## GET /v1/accounts/{accountId}/messenger-menu **Get FB persistent menu** Get the persistent menu configuration for a Facebook Messenger account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Persistent menu configuration **Response Body:** - **data** `array[object]`: Type: `object` #### 400: Not a Facebook account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/messenger-menu **Set FB persistent menu** Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. ### Parameters - **accountId** (required) in path: No description ### Request Body - **persistent_menu** (required) `array`: Persistent menu configuration array (Meta format) ### Responses #### 200: Menu set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/messenger-menu **Delete FB persistent menu** Removes the persistent menu from Facebook Messenger conversations for this account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Menu deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Set TG bot commands API Reference Set bot commands for a Telegram account. ## GET /v1/accounts/{accountId}/telegram-commands **Get TG bot commands** Get the bot commands configuration for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Bot commands list **Response Body:** - **data** `array[object]`: - **command** `string`: No description - **description** `string`: No description #### 400: Not a Telegram account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## PUT /v1/accounts/{accountId}/telegram-commands **Set TG bot commands** Set bot commands for a Telegram account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **commands** (required) `array`: No description ### Responses #### 200: Commands set successfully #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/accounts/{accountId}/telegram-commands **Delete TG bot commands** Clears all bot commands configured for a Telegram bot account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Commands deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Disconnect account API Reference Disconnects and removes a connected social account. ## PUT /v1/accounts/{accountId} **Update account** Updates a connected social account's display name or username override. For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities` object to toggle background API operations that incur X API pass-through costs. Both fields are opt-in (default `false`) — when off, no analytics syncs or DM polling are performed for that account, and no API call is metered for those operations. Publishing and deleting posts are always available regardless of these toggles. Setting `xCapabilities` on a non-X account returns 400. ### Parameters - **accountId** (required) in path: No description ### Request Body - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: X/Twitter only. Per-account opt-in toggles for background API operations that incur X API pass-through costs. Each call is billed via Metronome at the X tier rate. Either field can be sent independently; omitted fields are unchanged. ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: Echo of the resulting `xCapabilities` state, returned only when the request body included an `xCapabilities` object. - **analytics** `boolean`: No description - **inbox** `boolean`: No description #### 400: Invalid request (e.g. xCapabilities on a non-X account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/accounts/{accountId} **Move account to a different profile** Moves a connected social account to a different profile owned by the same user. The target profile must belong to the same user as the account. For API keys restricted to specific profiles, BOTH the source account's current profile AND the target profile must be in the key's allowed set. Calls with a target profile outside the key's scope return 403. ### Parameters - **accountId** (required) in path: No description ### Request Body - **profileId** (required) `string`: Target profile ID (must be a valid ObjectId and owned by the same user as the account). ### Responses #### 200: Account moved **Response Body:** - **message** `string`: No description - **profileId** `string`: No description #### 400: Missing or invalid profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: API key does not have access to the source account or target profile #### 404: Account or target profile not found --- ## DELETE /v1/accounts/{accountId} **Disconnect account** Disconnects and removes a connected social account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Disconnected **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Check account health API Reference Returns detailed health info for a specific account including token status, permissions, and recommendations. ## GET /v1/accounts/{accountId}/health **Check account health** Returns detailed health info for a specific account including token status, permissions, and recommendations. ### Parameters - **accountId** (required) in path: The account ID to check ### Responses #### 200: Account health details **Response Body:** - **accountId** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **status** `string`: Overall health status - one of: healthy, warning, error - **tokenStatus** `object`: - **valid** `boolean`: Whether the token is valid - **expiresAt** `string` (date-time): No description - **expiresIn** `string`: Human-readable time until expiry - **needsRefresh** `boolean`: Whether token expires within 24 hours - **permissions** `object`: - **posting** `array[object]`: - **scope** `string`: No description - **granted** `boolean`: No description - **required** `boolean`: No description - **analytics** `array[object]`: - **scope** `string`: No description - **granted** `boolean`: No description - **required** `boolean`: No description - **optional** `array[object]`: - **scope** `string`: No description - **granted** `boolean`: No description - **required** `boolean`: No description - **canPost** `boolean`: No description - **canFetchAnalytics** `boolean`: No description - **missingRequired** `array[string]`: - **issues** `array[string]`: List of issues found - **recommendations** `array[string]`: Actionable recommendations to fix issues #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Check accounts health API Reference Returns health status of all connected accounts including token validity, permissions, and issues needing attention. ## GET /v1/accounts/health **Check accounts health** Returns health status of all connected accounts including token validity, permissions, and issues needing attention. ### Parameters - **profileId** (optional) in query: Filter by profile ID - **platform** (optional) in query: Filter by platform - **status** (optional) in query: Filter by health status ### Responses #### 200: Account health summary **Response Body:** - **summary** `object`: - **total** `integer`: Total number of accounts - **healthy** `integer`: Number of healthy accounts - **warning** `integer`: Number of accounts with warnings - **error** `integer`: Number of accounts with errors - **needsReconnect** `integer`: Number of accounts needing reconnection - **accounts** `array[object]`: - **accountId** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **profileId** `string`: No description - **status** `string`: No description - one of: healthy, warning, error - **canPost** `boolean`: No description - **canFetchAnalytics** `boolean`: No description - **tokenValid** `boolean`: No description - **tokenExpiresAt** `string` (date-time): No description - **needsReconnect** `boolean`: No description - **issues** `array[string]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Get follower stats API Reference Returns follower count history and growth metrics for connected social accounts. Requires analytics add-on subscription. Follower counts are refreshed once per day. ## GET /v1/accounts/follower-stats **Get follower stats** Returns follower count history and growth metrics for connected social accounts. Requires analytics add-on subscription. Follower counts are refreshed once per day. ### Parameters - **accountIds** (optional) in query: Comma-separated list of account IDs (optional, defaults to all user's accounts) - **profileId** (optional) in query: Filter by profile ID - **fromDate** (optional) in query: Start date in YYYY-MM-DD format (defaults to 30 days ago) - **toDate** (optional) in query: End date in YYYY-MM-DD format (defaults to today) - **granularity** (optional) in query: Data aggregation level ### Responses #### 200: Follower stats **Response Body:** - **accounts** `array[AccountWithFollowerStats]`: - **stats** `object`: No description - **dateRange** `object`: - **from** `string` (date-time): No description - **to** `string` (date-time): No description - **granularity** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **message** `string`: No description (example: "Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature.") - **requiresAddon** `boolean`: No description (example: true) --- # Related Schema Definitions ## AccountWithFollowerStats --- # Get TikTok creator info API Reference Returns TikTok creator details, available privacy levels, posting limits, and commercial content options for a specific TikTok account. Only works with TikTok accounts. ## GET /v1/accounts/{accountId}/tiktok/creator-info **Get TikTok creator info** Returns TikTok creator details, available privacy levels, posting limits, and commercial content options for a specific TikTok account. Only works with TikTok accounts. ### Parameters - **accountId** (required) in path: The TikTok account ID - **mediaType** (optional) in query: The media type to get creator info for (affects available interaction settings) ### Responses #### 200: TikTok creator info and posting options **Response Body:** - **creator** `object`: - **nickname** `string`: Creator display name - **avatarUrl** `string`: Creator avatar URL - **isVerified** `boolean`: Whether the creator is verified - **canPostMore** `boolean`: Whether the creator can publish more posts right now - **privacyLevels** `array[object]`: Available privacy level options for this creator - **value** `string`: Privacy level value to use when creating posts (e.g. PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY) - **label** `string`: Human-readable label - **postingLimits** `object`: - **maxVideoDurationSec** `integer`: Maximum video duration in seconds - **interactionSettings** `object`: Available interaction toggles (comment, duet, stitch) and their defaults - **commercialContentTypes** `array[object]`: Available commercial content disclosure options - **value** `string`: No description - **label** `string`: No description - **requires** `array[string]`: #### 400: Account is not a TikTok account **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 429: Creator has reached TikTok daily posting limit **Response Body:** - **error** `string`: No description --- --- # List accounts API Reference 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). ## GET /v1/accounts **List accounts** 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 - **profileId** (optional) in query: Filter accounts by profile ID - **platform** (optional) in query: Filter accounts by platform (e.g. "instagram", "twitter"). - **status** (optional) in query: Filter accounts by connection status. `connected` returns healthy accounts; `disconnected` returns accounts that need reconnection (per the same reconnection check surfaced in the dashboard). Omit to return accounts in any status. When combined with page/limit, pagination totals reflect the filtered result set. - **includeOverLimit** (optional) in query: When true, includes accounts from over-limit profiles. - **page** (optional) in query: Page number (1-based). When provided with limit, enables server-side pagination. Omit for all accounts. - **limit** (optional) in query: Page size. Required alongside page for pagination. ### Responses #### 200: Accounts (with optional pagination) **Response Body:** - **accounts** (required) `array[SocialAccount]`: - **hasAnalyticsAccess** (required) `boolean`: Whether user has analytics add-on access - **pagination**: `Pagination` - Only present when page/limit params are provided #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## SocialAccount ### Properties - **_id** (required) `string`: No description - **platform** (required) `string`: No description - one of: tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads - **profileId** (required): No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: URL to the account's profile picture on the platform. May be null if the platform does not provide one. - **profileUrl** `string`: Full profile URL for the connected account on its platform. - **isActive** (required) `boolean`: No description - **followersCount** `number`: Follower count (only included if user has analytics add-on) - **followersLastUpdated** `string`: Last time follower count was updated (only included if user has analytics add-on) - **parentAccountId** `string`: 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** `boolean`: 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** `object`: 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 ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description --- # Move account to a different profile API Reference Moves a connected social account to a different profile owned by the same user. The target profile must belong to the same user as the account. For API keys restricted to specific profiles, BOTH the source account's current profile AND the target profile must be in the key's allowed set. Calls with a target profile outside the key's scope return 403. ## PUT /v1/accounts/{accountId} **Update account** Updates a connected social account's display name or username override. For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities` object to toggle background API operations that incur X API pass-through costs. Both fields are opt-in (default `false`) — when off, no analytics syncs or DM polling are performed for that account, and no API call is metered for those operations. Publishing and deleting posts are always available regardless of these toggles. Setting `xCapabilities` on a non-X account returns 400. ### Parameters - **accountId** (required) in path: No description ### Request Body - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: X/Twitter only. Per-account opt-in toggles for background API operations that incur X API pass-through costs. Each call is billed via Metronome at the X tier rate. Either field can be sent independently; omitted fields are unchanged. ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: Echo of the resulting `xCapabilities` state, returned only when the request body included an `xCapabilities` object. - **analytics** `boolean`: No description - **inbox** `boolean`: No description #### 400: Invalid request (e.g. xCapabilities on a non-X account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/accounts/{accountId} **Move account to a different profile** Moves a connected social account to a different profile owned by the same user. The target profile must belong to the same user as the account. For API keys restricted to specific profiles, BOTH the source account's current profile AND the target profile must be in the key's allowed set. Calls with a target profile outside the key's scope return 403. ### Parameters - **accountId** (required) in path: No description ### Request Body - **profileId** (required) `string`: Target profile ID (must be a valid ObjectId and owned by the same user as the account). ### Responses #### 200: Account moved **Response Body:** - **message** `string`: No description - **profileId** `string`: No description #### 400: Missing or invalid profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: API key does not have access to the source account or target profile #### 404: Account or target profile not found --- ## DELETE /v1/accounts/{accountId} **Disconnect account** Disconnects and removes a connected social account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Disconnected **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Update account API Reference Updates a connected social account's display name or username override. For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities` object to toggle background API operations that incur X API pass-through costs. Both fields are opt-in (default `false`) — when off, no analytics syncs or DM polling are performed for that account, and no API call is metered for those operations. Publishing and deleting posts are always available regardless of these toggles. Setting `xCapabilities` on a non-X account returns 400. ## PUT /v1/accounts/{accountId} **Update account** Updates a connected social account's display name or username override. For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities` object to toggle background API operations that incur X API pass-through costs. Both fields are opt-in (default `false`) — when off, no analytics syncs or DM polling are performed for that account, and no API call is metered for those operations. Publishing and deleting posts are always available regardless of these toggles. Setting `xCapabilities` on a non-X account returns 400. ### Parameters - **accountId** (required) in path: No description ### Request Body - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: X/Twitter only. Per-account opt-in toggles for background API operations that incur X API pass-through costs. Each call is billed via Metronome at the X tier rate. Either field can be sent independently; omitted fields are unchanged. ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **xCapabilities** `object`: Echo of the resulting `xCapabilities` state, returned only when the request body included an `xCapabilities` object. - **analytics** `boolean`: No description - **inbox** `boolean`: No description #### 400: Invalid request (e.g. xCapabilities on a non-X account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/accounts/{accountId} **Move account to a different profile** Moves a connected social account to a different profile owned by the same user. The target profile must belong to the same user as the account. For API keys restricted to specific profiles, BOTH the source account's current profile AND the target profile must be in the key's allowed set. Calls with a target profile outside the key's scope return 403. ### Parameters - **accountId** (required) in path: No description ### Request Body - **profileId** (required) `string`: Target profile ID (must be a valid ObjectId and owned by the same user as the account). ### Responses #### 200: Account moved **Response Body:** - **message** `string`: No description - **profileId** `string`: No description #### 400: Missing or invalid profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: API key does not have access to the source account or target profile #### 404: Account or target profile not found --- ## DELETE /v1/accounts/{accountId} **Disconnect account** Disconnects and removes a connected social account. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Disconnected **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Add users to audience API Reference Upload user data to a customer_list audience. Data is SHA256-hashed server-side before sending to the platform. Email is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest, the first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. Max 10,000 users per request. ## POST /v1/ads/audiences/{audienceId}/users **Add users to audience** Upload user data to a customer_list audience. Data is SHA256-hashed server-side before sending to the platform. Email is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest, the first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. Max 10,000 users per request. ### Parameters - **audienceId** (required) in path: No description ### Request Body - **users** (required) `array`: No description ### Responses #### 200: Users added **Response Body:** - **message** `string`: No description - **numReceived** `integer`: No description - **numInvalid** `integer`: No description #### 400: Invalid input (empty users array, missing email/phone) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 422: Audience is not a customer_list type or has no platform ID yet --- --- # Create custom audience API Reference Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest; `website` and `lookalike` are Meta-only. `saved_targeting` stores a reusable TargetingSpec (no member upload, no adAccountId) that you reference later via `savedTargetingId` on `POST /v1/ads/create`. Upload-backed audiences are created empty, add members via `POST /v1/ads/audiences/{audienceId}/users`. On TikTok and Pinterest the audience is provisioned lazily on the first member upload (until then its status is `pending`). Create is not idempotent, never auto-retry. ## GET /v1/ads/audiences **List custom audiences** Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X (Twitter). ### Parameters - **accountId** (required) in query: Social account ID - **adAccountId** (required) in query: Platform ad account ID - **platform** (optional) in query: No description - **type** (optional) in query: Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences (each item carries a `spec`); the other types return uploaded/derived audiences. ### Responses #### 200: Audiences **Response Body:** - **audiences** `array[object]`: - **id** `string`: No description - **platformAudienceId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **type** `string`: No description - one of: customer_list, website, lookalike, saved_targeting - **spec**: Present (and the only meaningful payload) when `type` is `saved_targeting`. Null for uploaded/derived audience types. - **platform** `string`: No description - **size** `integer`: No description - **status** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- ## POST /v1/ads/audiences **Create custom audience** Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest; `website` and `lookalike` are Meta-only. `saved_targeting` stores a reusable TargetingSpec (no member upload, no adAccountId) that you reference later via `savedTargetingId` on `POST /v1/ads/create`. Upload-backed audiences are created empty, add members via `POST /v1/ads/audiences/{audienceId}/users`. On TikTok and Pinterest the audience is provisioned lazily on the first member upload (until then its status is `pending`). Create is not idempotent, never auto-retry. ### Request Body - **platformSpecificData**: Platform-specific settings (see schema definitions below) ### Responses #### 201: Audience created **Response Body:** - **audience** `object`: No description - **message** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- --- # Delete custom audience API Reference Deletes the audience from both Meta and the local database. ## GET /v1/ads/audiences/{audienceId} **Get audience details** Returns the local audience record and fresh data from Meta (if available). ### Parameters - **audienceId** (required) in path: No description ### Responses #### 200: Audience details **Response Body:** - **audience** `object`: No description - **metaData** `object`: Fresh data from Meta API #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/ads/audiences/{audienceId} **Delete custom audience** Deletes the audience from both Meta and the local database. ### Parameters - **audienceId** (required) in path: No description ### Responses #### 200: Audience deleted **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Get audience details API Reference Returns the local audience record and fresh data from Meta (if available). ## GET /v1/ads/audiences/{audienceId} **Get audience details** Returns the local audience record and fresh data from Meta (if available). ### Parameters - **audienceId** (required) in path: No description ### Responses #### 200: Audience details **Response Body:** - **audience** `object`: No description - **metaData** `object`: Fresh data from Meta API #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/ads/audiences/{audienceId} **Delete custom audience** Deletes the audience from both Meta and the local database. ### Parameters - **audienceId** (required) in path: No description ### Responses #### 200: Audience deleted **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List custom audiences API Reference Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X (Twitter). ## GET /v1/ads/audiences **List custom audiences** Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X (Twitter). ### Parameters - **accountId** (required) in query: Social account ID - **adAccountId** (required) in query: Platform ad account ID - **platform** (optional) in query: No description - **type** (optional) in query: Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences (each item carries a `spec`); the other types return uploaded/derived audiences. ### Responses #### 200: Audiences **Response Body:** - **audiences** `array[object]`: - **id** `string`: No description - **platformAudienceId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **type** `string`: No description - one of: customer_list, website, lookalike, saved_targeting - **spec**: Present (and the only meaningful payload) when `type` is `saved_targeting`. Null for uploaded/derived audience types. - **platform** `string`: No description - **size** `integer`: No description - **status** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- ## POST /v1/ads/audiences **Create custom audience** Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest; `website` and `lookalike` are Meta-only. `saved_targeting` stores a reusable TargetingSpec (no member upload, no adAccountId) that you reference later via `savedTargetingId` on `POST /v1/ads/create`. Upload-backed audiences are created empty, add members via `POST /v1/ads/audiences/{audienceId}/users`. On TikTok and Pinterest the audience is provisioned lazily on the first member upload (until then its status is `pending`). Create is not idempotent, never auto-retry. ### Request Body - **platformSpecificData**: Platform-specific settings (see schema definitions below) ### Responses #### 201: Audience created **Response Body:** - **audience** `object`: No description - **message** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- --- # Pause or resume many campaigns API Reference 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. ## POST /v1/ads/campaigns/bulk-status **Pause or resume many campaigns** 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. ### Request Body - **status** (required) `string`: No description - one of: active, paused - **campaigns** (required) `array`: No description ### Responses #### 200: Per-campaign results **Response Body:** - **status** `string`: No description - one of: active, paused - **totals** `object`: - **updated** `integer`: No description - **skipped** `integer`: No description - **failed** `integer`: No description - **results** `array[object]`: - **platformCampaignId** `string`: No description - **platform** `string`: No description - **updated** `integer`: No description - **skipped** `integer`: No description - **error** `string`: No description #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Delete a campaign API Reference 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. ## PUT /v1/ads/campaigns/{campaignId} **Update a campaign (budget and/or bid strategy)** 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. ### Parameters - **campaignId** (required) in path: Platform campaign ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram - **budget** `object`: No description - **bidStrategy**: Campaign-level default. Ad sets inherit this unless they override. - **name** `string`: Rename the campaign (Meta only; other platforms return 501). At least one of budget/bidStrategy/name is required. ### Responses #### 200: Campaign updated **Response Body:** - **updated** `integer`: No description - **budget**: `AdBudget` - See schema definition - **budgetLevel** `string`: No description - one of: campaign - **bidStrategy**: `BidStrategy` - See schema definition #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Campaign not found #### 409: Campaign is ABO — route to /v1/ads/ad-sets/{adSetId} instead #### 501: Operation not supported on this platform --- ## DELETE /v1/ads/campaigns/{campaignId} **Delete a campaign** 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. ### Parameters - **campaignId** (required) in path: Platform campaign ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram ### Responses #### 200: Campaign deleted **Response Body:** - **deleted** `boolean`: No description - **adCount** `integer`: Number of local Ad docs marked cancelled #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Campaign not found #### 501: Operation not supported on this platform --- # Related Schema Definitions ## AdBudget Budget amount in the ad account's native currency (see the campaign's `currency` field for the code). ### Properties - **amount** (required) `number`: No description - **type** (required) `string`: No description - one of: daily, lifetime ## BidStrategy 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`). --- # Duplicate a campaign API Reference 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. ## POST /v1/ads/campaigns/{campaignId}/duplicate **Duplicate a campaign** 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. ### Parameters - **campaignId** (required) in path: Source platform campaign ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram, tiktok - **deepCopy** `boolean`: Copy child ad sets + ads + creatives + targeting - **statusOption** `string`: No description - one of: ACTIVE, PAUSED, INHERITED_FROM_SOURCE - **startTime** `string`: Reschedule the copied hierarchy's start time - **endTime** `string`: No description - **renameStrategy** `string`: No description - one of: DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME - **renamePrefix** `string`: No description - **renameSuffix** `string`: No description - **syncAfter** `boolean`: Trigger ads discovery on the owning account after the copy succeeds ### Responses #### 200: Campaign duplicated **Response Body:** - **copiedCampaignId** `string`: Platform ID of the new campaign - **discovery** `string`: No description - one of: triggered, skipped, failed - **raw** `object`: Platform-native response from the copy endpoint (Meta includes ad_object_ids for child copies) #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Source campaign not found #### 501: Operation not supported on this platform --- --- # Get campaign tree API Reference Returns a nested Campaign > Ad Set > Ad hierarchy with rolled-up metrics at each level. Uses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns. Metrics are computed over an optional date range, then rolled up from ad level to ad set and campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set ID are grouped into synthetic "Ungrouped" buckets. If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. ## GET /v1/ads/tree **Get campaign tree** Returns a nested Campaign > Ad Set > Ad hierarchy with rolled-up metrics at each level. Uses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns. Metrics are computed over an optional date range, then rolled up from ad level to ad set and campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set ID are grouped into synthetic "Ungrouped" buckets. If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. ### Parameters - **undefined** (optional): No description - **limit** (optional) in query: Campaigns per page - **source** (optional) in query: `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. - **platform** (optional) in query: No description - **status** (optional) in query: Filter by derived campaign status (post-aggregation) - **adAccountId** (optional) in query: Platform ad account ID - **accountId** (optional) in query: Social account ID - **profileId** (optional) in query: Profile ID - **fromDate** (optional) in query: Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago. - **toDate** (optional) in query: End of metrics date range (YYYY-MM-DD). Defaults to today. Max 730-day range. - **sort** (optional) in query: Campaign-level sort order. `newest` (default) / `oldest` order by the campaign's newest-ad createdAt. `spend_desc` / `spend_asc` order by aggregated spend in the requested date range; campaigns with no spend land at the end. ### Responses #### 200: Nested campaign tree with pagination **Response Body:** - **campaigns** `array[AdTreeCampaign]`: - **pagination**: `Pagination` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- # Related Schema Definitions ## AdTreeCampaign Campaign with nested ad sets and rolled-up metrics ### Properties - **platformCampaignId** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **campaignName** `string`: No description - **status**: Delivery status derived from child ad statuses. Distinct from `reviewStatus`, which reflects the platform-side review state. - **reviewStatus** `string`: 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. - one of: in_review, approved, rejected, with_issues - **platformCampaignStatus** `string`: Raw platform-level campaign status (Meta `effective_status`: ACTIVE, PAUSED, DELETED, ARCHIVED, IN_PROCESS, WITH_ISSUES). Distinct from per-ad `platformStatus`. - **campaignIssuesInfo** `array`: Platform-reported campaign issues (Meta `issues_info[]`). Populated only when the platform has delivery issues to report; contains the specific error codes and messages. - **adCount** `integer`: Total ads across all ad sets - **adSetCount** `integer`: No description - **budget** `object`: Effective budget (back-compat). For CBO this mirrors `campaignBudget`, for ABO this mirrors the child ad-set budget. Use `budgetLevel` to disambiguate. - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **campaignBudget** `object`: 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. - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **budgetLevel** `string`: 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. - one of: campaign, adset - **isBudgetScheduleEnabled** `boolean`: Meta-only. Mirrors Campaign.is_budget_schedule_enabled — true when the campaign uses budget scheduling (time-based budget changes). Independent of CBO/ABO. (default: false) - **currency** `string`: 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**: No description - **platformAdAccountId** `string`: No description - **platformAdAccountName** `string`: Human-readable advertiser/account name from the platform. Refreshed on every sync. - **accountId** `string`: No description - **profileId** `string`: No description - **advertisingChannelType** `string`: Google-only. Raw campaign.advertising_channel_type (SEARCH, PERFORMANCE_MAX, VIDEO, DEMAND_GEN, DISPLAY, SHOPPING, ...). Serving surface, distinct from platformObjective (advertiser intent). Null/absent for non-Google platforms. - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC) - **optimizationGoal** `string`: Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION) - **bidStrategy**: Campaign-level bid strategy. Ad sets inherit this unless they override. - **bidAmount** `number`: 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** `number`: Representative ROAS floor for the campaign — bubbled up from the top-spending ad set. Decimal multiplier (2.0 = 2.0x). - **promotedObject** `object`: Meta promoted object at campaign level (conversion event details) - **custom_event_type** `string`: - **pixel_id** `string`: - **page_id** `string`: - **adSets** `array`: No description ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description --- # Get daily aggregate ad metrics for an account API Reference Returns daily aggregate metrics across all ads in a SocialAccount as a single time series — one row per calendar day in the requested range. Use this for dashboards that draw a daily-spend or daily-conversions chart, instead of calling `/v1/ads/tree` once per day. `accountId` is required. The lookup is sibling-expanded so passing the `metaads` ID also includes ads under the linked `facebook` / `instagram` posting account (and vice-versa) — same convention as `/v1/ads/tree` and `/v1/ads`. Date range defaults to the last 90 days. Capped at 730 days. Ranges older than the 90-day cache window trigger an on-demand backfill from the platform before returning. ## GET /v1/ads/timeline **Get daily aggregate ad metrics for an account** Returns daily aggregate metrics across all ads in a SocialAccount as a single time series — one row per calendar day in the requested range. Use this for dashboards that draw a daily-spend or daily-conversions chart, instead of calling `/v1/ads/tree` once per day. `accountId` is required. The lookup is sibling-expanded so passing the `metaads` ID also includes ads under the linked `facebook` / `instagram` posting account (and vice-versa) — same convention as `/v1/ads/tree` and `/v1/ads`. Date range defaults to the last 90 days. Capped at 730 days. Ranges older than the 90-day cache window trigger an on-demand backfill from the platform before returning. ### Parameters - **accountId** (required) in query: Social account ID. Sibling-expanded to its linked posting↔ads pair. - **adAccountId** (optional) in query: Optional platform-native ad account ID (e.g. Meta `act_…`, TikTok advertiser ID). Use when the connection wraps multiple platform ad accounts and the chart should show one only. Note: rows ingested before 2026-05-13 don't carry this column; the recurring 7-day re-sync repopulates them naturally. - **fromDate** (optional) in query: Inclusive start of metrics range (YYYY-MM-DD). Defaults to 90 days ago. - **toDate** (optional) in query: Inclusive end of metrics range (YYYY-MM-DD). Defaults to today. Max 730-day range. - **platform** (optional) in query: Restrict to one platform. ### Responses #### 200: Daily time series of aggregate metrics. Empty `rows` means the account has no ad activity in the range. **Response Body:** - **rows** `array[object]`: - **date** `string` (date): No description - **spend** `number`: Native currency units (matches /ads/tree convention). - **impressions** `integer`: No description - **reach** `integer`: No description - **clicks** `integer`: No description - **engagement** `integer`: No description - **ctr** `number`: Click-through rate as a percentage (0–100). - **cpc** `number`: Cost per click in native currency. - **cpm** `number`: Cost per 1000 impressions in native currency. - **conversions** `integer`: Sum of conversion events matching the campaign optimization goal. Meta-only at time of writing. - **costPerConversion** `number`: No description - **actions** `object`: Per-action-type counts merged across all ads on this day. Keys are platform-native action types. - **actionValues** `object`: Monetary mirror of `actions` in native currency. - **purchaseValue** `number`: Sum of purchase-type action values on this day, native currency. - **roas** `number`: Derived purchaseValue / spend. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- --- # List campaigns API Reference 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). ## GET /v1/ads/campaigns **List campaigns** 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). ### Parameters - **undefined** (optional): No description - **limit** (optional) in query: No description - **source** (optional) in query: `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. - **platform** (optional) in query: No description - **status** (optional) in query: Filter by derived campaign status (post-aggregation) - **adAccountId** (optional) in query: Platform ad account ID (e.g. act_123 for Meta) - **accountId** (optional) in query: Social account ID - **profileId** (optional) in query: Profile ID - **fromDate** (optional) in query: Start of metrics date range (YYYY-MM-DD, inclusive). Defaults to 90 days ago when both date params are omitted. - **toDate** (optional) in query: End of metrics date range (YYYY-MM-DD, inclusive). Defaults to today. Max 730-day range. ### Responses #### 200: Paginated campaigns **Response Body:** - **campaigns** `array[AdCampaign]`: - **pagination**: `Pagination` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- # Related Schema Definitions ## AdCampaign ### Properties - **platformCampaignId** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **campaignName** `string`: No description - **status**: Delivery status derived from child ad statuses. Distinct from `reviewStatus`. - **reviewStatus** `string`: Platform-side review state of the campaign. See AdTreeCampaign.reviewStatus for the full description. - one of: in_review, approved, rejected, with_issues - **platformCampaignStatus** `string`: Raw platform-level campaign status (Meta `effective_status`). - **campaignIssuesInfo** `array`: Platform-reported campaign issues (Meta `issues_info[]`). - **adCount** `integer`: No description - **budget** `object`: Effective budget (back-compat). Use `budgetLevel` to disambiguate CBO vs ABO. - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **campaignBudget** `object`: Campaign-level budget (CBO). Null for ABO campaigns. - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **budgetLevel** `string`: Canonical CBO/ABO indicator. See AdTreeCampaign.budgetLevel. - one of: campaign, adset - **isBudgetScheduleEnabled** `boolean`: Meta-only. Mirrors Campaign.is_budget_schedule_enabled. (default: false) - **currency** `string`: ISO 4217 currency code for all budget amounts. Budgets are NOT normalized to USD. - **metrics**: No description - **platformAdAccountId** `string`: No description - **platformAdAccountName** `string`: Human-readable advertiser/account name from the platform. Refreshed on every sync. - **accountId** `string`: No description - **profileId** `string`: No description - **advertisingChannelType** `string`: Google-only. Raw campaign.advertising_channel_type. See AdTreeCampaign.advertisingChannelType. - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC) - **optimizationGoal** `string`: Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION) - **bidStrategy**: Campaign-level bid strategy. Ad sets inherit this unless they override. - **bidAmount** `number`: 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** `number`: Representative ROAS floor from the top-spending ad set. Decimal multiplier (2.0 = 2.0x). - **promotedObject** `object`: Meta promoted object at campaign level (conversion event details) - **custom_event_type** `string`: - **pixel_id** `string`: - **page_id** `string`: - **earliestAd** `string`: No description - **latestAd** `string`: No description ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description --- # Pause or resume a campaign API Reference 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. ## PUT /v1/ads/campaigns/{campaignId}/status **Pause or resume a campaign** 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. ### Parameters - **campaignId** (required) in path: Platform campaign ID ### Request Body - **status** (required) `string`: No description - one of: active, paused - **platform** (required) `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter ### Responses #### 200: Campaign status updated **Response Body:** - **updated** `integer`: Number of ads updated - **skipped** `integer`: Number of ads skipped - **skippedReasons** `array[string]`: - **message** `string`: Human-readable summary (present when no ads were actionable) #### 400: Invalid input or campaign spans multiple social accounts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: No ads found for this campaign --- --- # Update a campaign (budget and/or bid strategy) API Reference 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. ## PUT /v1/ads/campaigns/{campaignId} **Update a campaign (budget and/or bid strategy)** 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. ### Parameters - **campaignId** (required) in path: Platform campaign ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram - **budget** `object`: No description - **bidStrategy**: Campaign-level default. Ad sets inherit this unless they override. - **name** `string`: Rename the campaign (Meta only; other platforms return 501). At least one of budget/bidStrategy/name is required. ### Responses #### 200: Campaign updated **Response Body:** - **updated** `integer`: No description - **budget**: `AdBudget` - See schema definition - **budgetLevel** `string`: No description - one of: campaign - **bidStrategy**: `BidStrategy` - See schema definition #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Campaign not found #### 409: Campaign is ABO — route to /v1/ads/ad-sets/{adSetId} instead #### 501: Operation not supported on this platform --- ## DELETE /v1/ads/campaigns/{campaignId} **Delete a campaign** 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. ### Parameters - **campaignId** (required) in path: Platform campaign ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram ### Responses #### 200: Campaign deleted **Response Body:** - **deleted** `boolean`: No description - **adCount** `integer`: Number of local Ad docs marked cancelled #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Campaign not found #### 501: Operation not supported on this platform --- # Related Schema Definitions ## AdBudget Budget amount in the ad account's native currency (see the campaign's `currency` field for the code). ### Properties - **amount** (required) `number`: No description - **type** (required) `string`: No description - one of: daily, lifetime ## BidStrategy 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`). --- # Pause or resume a single ad set API Reference 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. ## PUT /v1/ads/ad-sets/{adSetId}/status **Pause or resume a single ad set** 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. ### Parameters - **adSetId** (required) in path: Platform ad set ID ### Request Body - **status** (required) `string`: No description - one of: active, paused - **platform** (required) `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter ### Responses #### 200: Ad set status updated **Response Body:** - **updated** `integer`: No description - **skipped** `integer`: No description #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad set not found --- --- # Update an ad set (budget, status, and/or bid strategy) API Reference 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. ## PUT /v1/ads/ad-sets/{adSetId} **Update an ad set (budget, status, and/or bid strategy)** 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. ### Parameters - **adSetId** (required) in path: Platform ad set ID ### Request Body - **platform** (required) `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **budget** `object`: Omit if not updating budget - **status** `string`: Omit if not toggling delivery state - one of: active, paused - **name** `string`: Rename the ad set (Meta only; other platforms return 501). At least one of budget/status/bidStrategy/name is required. - **bidStrategy**: 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** `number`: 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** `number`: 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: Ad set updated **Response Body:** - **budget**: `AdBudget` - See schema definition - **budgetLevel** `string`: No description - one of: adset - **status** `string`: No description - one of: active, paused - **statusUpdated** `integer`: No description - **statusSkipped** `integer`: No description - **bidStrategy**: `BidStrategy` - See schema definition - **bidAmount** `number`: No description - **roasAverageFloor** `number`: No description #### 400: Invalid input #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad set not found #### 409: Campaign is CBO — route to /v1/ads/campaigns/{campaignId} instead #### 501: bidStrategy not supported on the platform (Meta + TikTok only) --- # Related Schema Definitions ## AdBudget Budget amount in the ad account's native currency (see the campaign's `currency` field for the code). ### Properties - **amount** (required) `number`: No description - **type** (required) `string`: No description - one of: daily, lifetime ## BidStrategy 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`). --- # Associate campaigns with a conversion destination API Reference Associate one or more campaigns with this conversion rule. Returns a per-campaign success/failure result so callers can retry only the rows that failed (e.g. wrong campaign type for the rule's objective). ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **List campaigns associated with a conversion destination** LinkedIn-only today. Returns the campaigns currently associated with this conversion rule. Note that auto-association on rule creation runs once at create time; campaigns created after the rule still need explicit association. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description ### Responses #### 200: Associations listed **Response Body:** - **platform** `string`: No description - one of: linkedinads - **associations** `array[object]`: - **campaignId** `string`: No description - **conversionId** `string`: No description - **associatedAt** `integer`: Epoch ms. #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## POST /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Associate campaigns with a conversion destination** Associate one or more campaigns with this conversion rule. Returns a per-campaign success/failure result so callers can retry only the rows that failed (e.g. wrong campaign type for the rule's objective). ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **campaignIds** (required) `array`: No description ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. Inputs that fail local URN validation are bucketed into `failed` without ever hitting LinkedIn. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully associated. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Remove campaign↔conversion associations** Remove one or more campaign associations from this conversion rule. Pass `adAccountId` and `campaignIds` as query parameters (`campaignIds` is comma-separated). The route also accepts a JSON body with the same fields for clients that prefer DELETE-with-body, but the documented surface is query-only because some SDK code generators (e.g. Python) collapse query + body parameters with the same name into a single kwarg. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description - **campaignIds** (required) in query: Comma-separated list of campaign IDs. ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully removed. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Validation error: missing `adAccountId` or `campaignIds`, or campaignIds exceeds 100 entries per request. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- --- # Adjust already-uploaded conversions (Google only) API Reference Adjust conversions that were previously uploaded via `POST /v1/ads/conversions` — retract them, restate their value, or enhance them with first-party data. Requires the Ads add-on. **Google Ads only.** Google handles adjustments through the classic Google Ads API (`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for sending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this endpoint returns `405` for those platforms. Adjustment types: - `RETRACTION` — remove the conversion entirely (refund, chargeback, cancelled order, churn). - `RESTATEMENT` — change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta). - `ENHANCEMENT` — attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact). Identifying the original conversion (per adjustment): - `orderId` — the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`. - or `gclid` + `conversionTime` — the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`. `destinationId` is the conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321` (same value you send to `POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side (Gmail-specific normalization included). Send plaintext. Times are unix seconds; we convert to Google's required `yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial failure is supported (inspect `adjustmentsFailed` / `failures[]`). ## POST /v1/ads/conversions/adjustments **Adjust already-uploaded conversions (Google only)** Adjust conversions that were previously uploaded via `POST /v1/ads/conversions` — retract them, restate their value, or enhance them with first-party data. Requires the Ads add-on. **Google Ads only.** Google handles adjustments through the classic Google Ads API (`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for sending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this endpoint returns `405` for those platforms. Adjustment types: - `RETRACTION` — remove the conversion entirely (refund, chargeback, cancelled order, churn). - `RESTATEMENT` — change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta). - `ENHANCEMENT` — attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact). Identifying the original conversion (per adjustment): - `orderId` — the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`. - or `gclid` + `conversionTime` — the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`. `destinationId` is the conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321` (same value you send to `POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side (Gmail-specific normalization included). Send plaintext. Times are unix seconds; we convert to Google's required `yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial failure is supported (inspect `adjustmentsFailed` / `failures[]`). ### Request Body - **accountId** (required) `string`: SocialAccount ID. Must be a `googleads` account. - **destinationId** (required) `string`: Conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`. - **adjustments** (required) `array`: No description ### Responses #### 200: Adjustments processed. Inspect `adjustmentsFailed` and `failures[]` for partial failure (Google reports per-row errors via partial failure). **Response Body:** - **platform** `string`: No description - one of: googleads - **adjustmentsReceived** `integer`: Adjustments accepted by Google. - **adjustmentsFailed** `integer`: Adjustments rejected (see failures). - **failures** `array[object]`: - **adjustmentIndex** `integer`: Index into the submitted adjustments array. - **message** `string`: No description - **code**: One of multiple types - **traceId** `string`: No description #### 400: Invalid body, or a malformed adjustment (missing key, missing restatementValue for RESTATEMENT, missing identifiers for ENHANCEMENT). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans). #### 404: Account not found or not accessible. #### 405: Conversion adjustments are only available for Google Ads (the account's platform is not `googleads`). --- --- # Archive a Lead Gen form API Reference Meta has no hard delete for forms; this archives the form (status=ARCHIVED). ## GET /v1/ads/lead-forms/{formId} **Get a single Lead Gen form** ### Parameters - **formId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Form metadata. **Response Body:** - **status** `string`: No description - **form** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/ads/lead-forms/{formId} **Archive a Lead Gen form** Meta has no hard delete for forms; this archives the form (status=ARCHIVED). ### Parameters - **formId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Archived. **Response Body:** - **status** `string`: No description - **formId** `string`: No description - **archived** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Boost post as ad API Reference Creates a paid ad campaign from an existing published post. Creates the full platform campaign hierarchy (campaign, ad set, ad). ## POST /v1/ads/boost **Boost post as ad** Creates a paid ad campaign from an existing published post. Creates the full platform campaign hierarchy (campaign, ad set, ad). ### Request Body - **postId** `string`: Zernio post ID (provide this or platformPostId) - **platformPostId** `string`: Platform post ID (alternative to postId) - **accountId** (required) `string`: Social account ID - **adAccountId** (required) `string`: Platform ad account ID - **name** (required) `string`: No description - **goal** (required) `string`: 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. - one of: engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion - **budget** (required) `object`: No description - **currency** `string`: No description - **schedule** `object`: No description - **targeting** `object`: No description - **bidStrategy**: Meta bid strategy applied to the ad set. On TikTok, mapped to `bid_type` / `bid_price` / `deep_bid_type` automatically. - **bidAmount** `number`: 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** `number`: 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** `object`: Meta only. Tracking specs (pixel, URL tags). - **specialAdCategories** `array`: Meta only. Required for housing, employment, credit, or political ads. - **linkUrl** `string`: 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** `string`: 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** `string`: 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** `string`: 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** `string`: 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: Ad created **Response Body:** - **ad**: `Ad` - See schema definition - **message** `string`: No description #### 400: Missing required fields or invalid values #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 422: 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. --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Create a conversion destination (LinkedIn, Google Ads) API Reference Create a new conversion destination on the platform. Supported for LinkedIn (conversion rule) and Google Ads (conversion action). Meta manages destinations in its own UI and returns 405. **LinkedIn:** creation is NOT idempotent. A retry creates a second destination. Deduplicate before retrying. **Google Ads:** calling with a name that already exists reuses the existing conversion action transparently (the response is identical to a fresh create). Calling with the same name but a different category returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently returning the mismatched action. **LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API` and (by default) auto-associated with all of the ad account's campaigns via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE` to opt out and manage associations explicitly via the associations endpoints below. 365-day attribution windows are only valid for `SUBMIT_APPLICATION`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types; the API rejects other combinations locally. **Google Ads:** the conversion action is created with `type=UPLOAD_CLICKS` (required for API-uploaded offline conversions, immutable after creation). The `type` field carries the Google `ConversionActionCategory` enum value, e.g. `PURCHASE`, `SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`. Unified standard event names (e.g. `Purchase`, `Subscribe`, `CompleteRegistration`, `Lead`, `Schedule`) are resolved to their Google category equivalents automatically. The action defaults to secondary (non-primary) to avoid immediately steering Smart Bidding; pass `primaryForGoal: true` to opt in. ## GET /v1/accounts/{accountId}/conversion-destinations **List destinations for the Conversions API** Returns the list of pixels (Meta), conversion actions (Google), or conversion rules (LinkedIn) accessible to the connected ads account. Use the returned `id` as `destinationId` when posting to `POST /v1/ads/conversions`. For Google and LinkedIn, each destination's `type` reflects the conversion type (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is locked to the destination. For Meta, `type` is absent: pixels accept any event name per request. For LinkedIn, destinations are returned across every sponsored ad account the connected token can access; the `adAccountId` field on each destination identifies the parent ad account and is required for subsequent CRUD calls (update, delete, associations, metrics). ### Parameters - **accountId** (required) in path: SocialAccount ID (metaads, googleads, or linkedinads). ### Responses #### 200: Destinations listed **Response Body:** - **platform** `string`: No description - one of: metaads, googleads, linkedinads - **destinations** `array[object]`: - **id** `string`: Destination identifier. Meta: pixel ID. Google: conversion action resource name. LinkedIn: numeric conversion rule ID. - **name** `string`: No description - **type** `string`: Present when the platform locks event type to the destination (Google conversion actions, LinkedIn conversion rules). - **status** `string`: No description - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls. #### 400: Account's platform is not supported by the Conversions API. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected. #### 404: Account not found or not accessible. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## POST /v1/accounts/{accountId}/conversion-destinations **Create a conversion destination (LinkedIn, Google Ads)** Create a new conversion destination on the platform. Supported for LinkedIn (conversion rule) and Google Ads (conversion action). Meta manages destinations in its own UI and returns 405. **LinkedIn:** creation is NOT idempotent. A retry creates a second destination. Deduplicate before retrying. **Google Ads:** calling with a name that already exists reuses the existing conversion action transparently (the response is identical to a fresh create). Calling with the same name but a different category returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently returning the mismatched action. **LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API` and (by default) auto-associated with all of the ad account's campaigns via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE` to opt out and manage associations explicitly via the associations endpoints below. 365-day attribution windows are only valid for `SUBMIT_APPLICATION`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types; the API rejects other combinations locally. **Google Ads:** the conversion action is created with `type=UPLOAD_CLICKS` (required for API-uploaded offline conversions, immutable after creation). The `type` field carries the Google `ConversionActionCategory` enum value, e.g. `PURCHASE`, `SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`. Unified standard event names (e.g. `Purchase`, `Subscribe`, `CompleteRegistration`, `Lead`, `Schedule`) are resolved to their Google category equivalents automatically. The action defaults to secondary (non-primary) to avoid immediately steering Smart Bidding; pass `primaryForGoal: true` to opt in. ### Parameters - **accountId** (required) in path: SocialAccount ID (linkedinads or googleads). ### Request Body - **adAccountId** (required) `string`: Ad account ID. For LinkedIn: numeric (e.g. "5123456") or full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric customer ID (e.g. "1234567890") or `customers/{id}` form. - **name** (required) `string`: No description - **type** (required) `string`: Conversion type. For LinkedIn: a unified standard event name (e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google: a unified standard event name (Purchase, Subscribe, CompleteRegistration, Lead, Schedule) or a Google ConversionActionCategory enum value directly (e.g. "PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD", "BOOK_APPOINTMENT"). Unknown values pass through to the platform. - **attributionType** `string`: LinkedIn only. - one of: LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION - **postClickAttributionWindowSize** `integer`: LinkedIn only. Default 30. 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types; the API rejects other combinations locally. - one of: 1, 7, 30, 90, 365 - **viewThroughAttributionWindowSize** `integer`: LinkedIn only. Default 7. Same 365-day-window type restriction applies as `postClickAttributionWindowSize`. - one of: 1, 7, 30, 90, 365 - **valueType** `string`: LinkedIn only. DYNAMIC (default) uses the per-event `value` from `sendConversions`. FIXED uses the rule's `value` field. NO_VALUE drops monetary value entirely. - one of: DYNAMIC, FIXED, NO_VALUE - **value** `object`: LinkedIn only. Static conversion value. Used when `valueType=FIXED`. The currency should match the ad account's currency. - **autoAssociationType** `string`: LinkedIn only. Controls campaign association at rule-creation time: - ALL_CAMPAIGNS: associate the rule with every active, paused, and draft campaign in the ad account - OBJECTIVE_BASED: associate only campaigns whose objective matches the rule's type - NONE: don't auto-associate. Manage associations via the `/associations` endpoints below. Note: auto-association runs once at create time; new campaigns added after the rule still need explicit association. - one of: ALL_CAMPAIGNS, OBJECTIVE_BASED, NONE - **countingType** `string`: Google Ads only. Whether to count multiple conversions from the same click (MANY_PER_CLICK) or at most one (ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted. - one of: MANY_PER_CLICK, ONE_PER_CLICK - **primaryForGoal** `boolean`: Google Ads only. When true, the conversion action is marked as primary and immediately influences Smart Bidding. Defaults to false (secondary, record-only) to avoid unintentionally steering the customer's campaigns on creation. ### Responses #### 201: Destination created **Response Body:** - **platform** `string`: No description - one of: linkedinads, googleads - **destination**: `ConversionDestination` - See schema definition #### 400: Invalid body or platform validation failure. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required). #### 404: Account not found or not accessible. #### 405: Platform does not support destination creation. #### 409: Google Ads only. A conversion action with the given name already exists but has a different category. Use a different name or use the existing destination. Error code: `IDEMPOTENCY_CONFLICT`. #### 429: Rate limit hit. Retry with backoff. --- # Related Schema Definitions ## ConversionDestination A discoverable conversion destination on an ad platform — a Meta pixel, Google conversion action, or LinkedIn conversion rule. Returned by `listConversionDestinations`, `getConversionDestination`, `createConversionDestination`, and `updateConversionDestination`. ### Properties - **id** (required) `string`: Platform-native identifier. Pass back as `destinationId` on event send and as the path segment on CRUD endpoints. - **name** (required) `string`: No description - **type** `string`: Present when the platform locks the event type/category to the destination (Google conversion actions, LinkedIn conversion rules). Absent for Meta pixels (which accept any event name per request). - **status** `string`: For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`). - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls to identify the parent ad account. --- # Create Click-to-WhatsApp ad(s) API Reference Creates one or more Click-to-WhatsApp (CTWA) ads on Meta under a single campaign and ad set. When tapped, each ad opens a WhatsApp conversation with the business attached to the supplied Facebook Page. The full hierarchy (campaign, ad set, creative(s), ad(s)) is created and activated in one call. The CTA is locked to WHATSAPP_MESSAGE and the destination is hard-coded to api.whatsapp.com/send; Meta resolves the actual WhatsApp number from the Page-to-WA pairing configured in Page settings or Business Manager. Supports two mutually-exclusive shapes: - **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`. Creates 1 campaign + 1 ad set + 1 ad. - **Multi-creative**: supply a `creatives[]` array with N entries (each carrying its own headline, body, and image/video). Creates 1 campaign + 1 ad set + N ads sharing budget and targeting so Meta A/Bs the creatives inside a single auction instead of fragmenting budget across N parallel campaigns. Recommended when launching multiple creative variants for the same campaign. 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. ## POST /v1/ads/ctwa **Create Click-to-WhatsApp ad(s)** Creates one or more Click-to-WhatsApp (CTWA) ads on Meta under a single campaign and ad set. When tapped, each ad opens a WhatsApp conversation with the business attached to the supplied Facebook Page. The full hierarchy (campaign, ad set, creative(s), ad(s)) is created and activated in one call. The CTA is locked to WHATSAPP_MESSAGE and the destination is hard-coded to api.whatsapp.com/send; Meta resolves the actual WhatsApp number from the Page-to-WA pairing configured in Page settings or Business Manager. Supports two mutually-exclusive shapes: - **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`. Creates 1 campaign + 1 ad set + 1 ad. - **Multi-creative**: supply a `creatives[]` array with N entries (each carrying its own headline, body, and image/video). Creates 1 campaign + 1 ad set + N ads sharing budget and targeting so Meta A/Bs the creatives inside a single auction instead of fragmenting budget across N parallel campaigns. Recommended when launching multiple creative variants for the same campaign. 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. ### Request Body - **accountId** (required) `string`: Facebook or Instagram SocialAccount ID. - **adAccountId** (required) `string`: Meta ad account ID, e.g. `act_123456789`. - **name** (required) `string`: Ad display name. Used to derive campaign / ad set names. On the multi-creative shape, each ad's Meta name gets a " #N" suffix (1-indexed) so Ads Manager shows them as a numbered batch. - **headline** `string`: Single-creative shape only. Mutually exclusive with `creatives[]`. - **body** `string`: Primary text shown above the image / video. Single-creative shape only. Mutually exclusive with `creatives[]`. - **imageUrl** `string`: Image asset for single-creative shape. Mutually exclusive with `video` and with `creatives[]`. Required on the single-creative shape if `video` is not supplied. - **video** `object`: Video creative for single-creative shape. Mutually exclusive with `imageUrl` and with `creatives[]`. Required on the single-creative shape if `imageUrl` is not supplied. - **creatives** `array`: Multi-creative shape: N CTWA ads under one campaign + one ad set, sharing budget and targeting. Mutually exclusive with the top-level single-creative fields (`headline` / `body` / `imageUrl` / `video`). Each entry must supply its own headline, body, and exactly one of `imageUrl` / `video`. - **budgetAmount** (required) `number`: Budget amount in the ad account's currency major units (e.g. dollars for USD, not cents). Must be > 0. - **budgetType** (required) `string`: No description - one of: daily, lifetime - **currency** `string`: ISO 4217 currency code matching the ad account's currency (e.g. `USD`). Optional; Meta infers from the ad account when omitted. - **endDate** `string`: ISO 8601 datetime. Required when `budgetType` is `lifetime`. - **countries** `array`: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only when no other geo (`cities`, `regions`, `zips`, `metros`, `customLocations`) is supplied. - **cities** `array`: City-level geo targeting for local CTWA campaigns (e.g. 25km radius around Milan). Each entry maps to Meta's TargetingGeoLocationCity. `key` is Meta's city ID (lookupable via GET /v1/ads/targeting/search). `radius` and `distance_unit` are coupled: set both or neither. - **regions** `array`: Region / state-level geo targeting. `key` is Meta's region ID (lookupable via GET /v1/ads/targeting/search?type=region). - **zips** `array`: ZIP / postal-code geo targeting. `key` is the platform's postal id resolved via /v1/ads/targeting/search. - **metros** `array`: DMA / metro-area geo targeting. `key` is Meta's metro id (e.g. `DMA:807`). - **customLocations** `array`: Point-radius geo (Meta `geo_locations.custom_locations`). Use for targeting a radius around a specific lat/long when no Meta city/region key fits. `distanceUnit` is required. - **ageMin** `integer`: No description - **ageMax** `integer`: No description - **interests** `array`: No description - **audienceId** `string`: Custom audience ID to target. - **advantageAudience** `integer`: 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). - one of: 0, 1 - **objective** `string`: 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. - one of: OUTCOME_ENGAGEMENT, OUTCOME_SALES, OUTCOME_LEADS - **bidStrategy** `string`: Meta bid strategy applied to the shared ad set. Defaults to `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted. `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires `roasAverageFloor`. CTWA's `optimization_goal` is fixed to `CONVERSATIONS`, but the bid strategy is independent. - one of: LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS - **bidAmount** `number`: Whole currency units (e.g. `5` = $5.00 on a USD account). Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; rejected otherwise. - **roasAverageFloor** `number`: Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor). Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`; rejected otherwise. Meta enforces its own upper bound server-side. - **dsaBeneficiary** `string`: 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** `string`: 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: CTWA ad(s) created and submitted to Meta for review. Response is a tagged union discriminated by `adType`: - `adType: "single"` → single-creative request: `{ adType, ad, message }` where `ad` is the persisted Ad document. - `adType: "multi"` → multi-creative request: `{ adType, ads, platformCampaignId, platformAdSetId, message }` where `ads` is the array of N persisted Ad documents all sharing the returned campaign and ad set IDs. Generated SDK clients can narrow on `adType` instead of sniffing for field presence. **Response Body:** *One of the following:* - `CtwaSingleResponse` - `CtwaMultiResponse` #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: SocialAccount not found. #### 422: Page is not connected to a verified WhatsApp number. #### 502: Meta rejected the request (e.g. WABA business verification missing). Inspect `platformError` for the upstream Meta payload. --- # Related Schema Definitions ## CtwaSingleResponse Response returned by `POST /v1/ads/ctwa` when the request used the single-creative shape (top-level headline / body / imageUrl|video). `adType` is the union discriminator. ### Properties - **adType** (required) `string`: No description - one of: single - **ad** (required) `object`: The persisted Ad document. - **message** (required) `string`: No description ## CtwaMultiResponse Response returned by `POST /v1/ads/ctwa` when the request used the multi-creative shape (`creatives[]`). N persisted Ad documents share the returned `platformCampaignId` and `platformAdSetId`. `adType` is the union discriminator. ### Properties - **adType** (required) `string`: No description - one of: multi - **ads** (required) `array`: The persisted Ad documents (one per creative), all sharing the same `platformCampaignId` and `platformAdSetId`. - **platformCampaignId** (required) `string`: No description - **platformAdSetId** (required) `string`: No description - **message** (required) `string`: No description --- # Create a Lead Gen (Instant) form API Reference Creates a Lead Gen form on the connected Facebook Page (POST /{page-id}/leadgen_forms). NOT idempotent — a retry creates a second form. Prefilled question types (EMAIL, PHONE, FULL_NAME, …) must omit label/key; CUSTOM questions require both. Requires the Ads add-on. ## GET /v1/ads/lead-forms **List Lead Gen (Instant) forms** Lists the Lead Gen forms owned by the connected Facebook Page. Requires the Ads add-on. ### Parameters - **accountId** (required) in query: Connected facebook account id. - **limit** (optional) in query: No description - **cursor** (optional) in query: No description ### Responses #### 200: Forms list. **Response Body:** - **status** `string`: No description (example: "success") - **forms** `array[object]`: Type: `object` - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on required. --- ## POST /v1/ads/lead-forms **Create a Lead Gen (Instant) form** Creates a Lead Gen form on the connected Facebook Page (POST /{page-id}/leadgen_forms). NOT idempotent — a retry creates a second form. Prefilled question types (EMAIL, PHONE, FULL_NAME, …) must omit label/key; CUSTOM questions require both. Requires the Ads add-on. ### Request Body - **accountId** (required) `string`: No description - **name** (required) `string`: No description - **questions** (required) `array`: No description - **privacyPolicyUrl** (required) `string`: No description - **privacyPolicyLinkText** `string`: No description - **followUpActionUrl** `string`: No description - **locale** `string`: No description - **thankYouTitle** `string`: No description - **thankYouBody** `string`: No description - **thankYouButtonText** `string`: No description - **thankYouButtonType** `string`: No description - **thankYouWebsiteUrl** `string`: No description - **isOptimizedForQuality** `boolean`: No description ### Responses #### 200: Created form. **Response Body:** - **status** `string`: No description (example: "success") - **form** `object`: - **id** `string`: No description - **name** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on required. --- --- # Create standalone ad API Reference Creates a paid ad with custom creative across Meta, Google Ads, Pinterest, TikTok, X/Twitter, and LinkedIn. 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 are documented on each property below. LinkedIn creates a Single Image or Single Video Ad backed by a Direct Sponsored Content "dark post" authored by a Company Page (see `organizationId`); supported goals are engagement, traffic, awareness, and video_views (video ads use the `video` field; video_views requires a video), and traffic ads require `linkUrl`. **Idempotency:** this endpoint is not idempotent at the platform level (a blind retry creates a second campaign/ad set/ad). Send an `Idempotency-Key` header to make retries safe: the first request with a given key creates the ad and we store the response; a retry with the same key replays that exact response (with `Idempotent-Replayed: true`) instead of creating duplicates. Reusing a key with a different body returns 422; a key whose first request is still in flight returns 409 (retry after a short backoff). Keys are scoped to your credential and expire after 24h. ## POST /v1/ads/create **Create standalone ad** Creates a paid ad with custom creative across Meta, Google Ads, Pinterest, TikTok, X/Twitter, and LinkedIn. 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 are documented on each property below. LinkedIn creates a Single Image or Single Video Ad backed by a Direct Sponsored Content "dark post" authored by a Company Page (see `organizationId`); supported goals are engagement, traffic, awareness, and video_views (video ads use the `video` field; video_views requires a video), and traffic ads require `linkUrl`. **Idempotency:** this endpoint is not idempotent at the platform level (a blind retry creates a second campaign/ad set/ad). Send an `Idempotency-Key` header to make retries safe: the first request with a given key creates the ad and we store the response; a retry with the same key replays that exact response (with `Idempotent-Replayed: true`) instead of creating duplicates. Reusing a key with a different body returns 422; a key whose first request is still in flight returns 409 (retry after a short backoff). Keys are scoped to your credential and expire after 24h. ### Parameters - **Idempotency-Key** (optional) in header: Optional client-generated unique key (e.g. a UUID) that makes create retries safe. Same key + same body replays the original response; same key + different body → 422; key still processing → 409. ### Request Body - **accountId** (required) `string`: No description - **adAccountId** (required) `string`: No description - **name** (required) `string`: No description - **campaignName** `string`: Meta only. Exact campaign name. Overrides the default ` - Campaign`. - **adSetName** `string`: Meta only. Exact ad set name. Overrides the default ` - Ad Set`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry.) - **adName** `string`: Meta only. Exact ad name (the single-creative ad object's name). Overrides the default, which is `name`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry instead.) - **tracking** `object`: Meta only. Attaches pixel measurement to the ad regardless of the optimization goal (the "Website events" tracking row in Ads Manager). `pixelId` becomes the ad's `tracking_specs` (offsite_conversion + fb_pixel); `urlTags` becomes the ad's `url_tags` (click-tracking query params). Applied on the legacy single-creative shape, every ad of the multi-creative shape, and the attach shape. NOTE: tracking lives on the AD object and is not inherited from the ad set, so pass it on EVERY attach call that should carry the pixel. - **goal** `string`: Required on legacy + multi-creative shapes. Inherited from the ad set on the attach shape. Available goals vary by platform. Meta-specific: `conversions` (OUTCOME_SALES) requires `promotedObject.pixelId` + `promotedObject.customEventType` (use a commerce event, e.g. PURCHASE, START_TRIAL); `lead_conversion` (OUTCOME_LEADS, website pixel leads) requires the same pixel + event but with a leads-class event (e.g. LEAD, SUBMIT_APPLICATION, SCHEDULE, CONTACT) — these are rejected under `conversions` because Meta gates conversion events by objective; `lead_generation` is OUTCOME_LEADS with instant forms (`leadGenFormId`), distinct from `lead_conversion`'s website pixel optimization; `app_promotion` requires `promotedObject.applicationId` + `promotedObject.objectStoreUrl`; `catalog_sales` (Advantage+ catalog ads, e.g. vehicle inventory) requires `promotedObject.productSetId` + `promotedObject.pixelId` + `promotedObject.customEventType` and builds a catalog TEMPLATE creative from the copy fields (headline/body/description/linkUrl/callToAction, which may carry catalog template tags like {{product.name}} or {{vehicle.make}}) — no imageUrl/video is sent, Meta renders the visuals per catalog item; discover catalogs via GET /v1/ads/catalogs and product sets via GET /v1/ads/catalogs/{catalogId}/product-sets; single shape only (no creatives[]/adSetId/dynamicCreative/placementAssets); `lead_generation` accepts an optional `promotedObject.pageId` (auto-filled from the connected Page when omitted). TikTok-specific: `conversions` (website-conversion ad group) requires `promotedObject.pixelId` (your TikTok Pixel ID) and accepts an optional `promotedObject.customEventType` (a TikTok `optimization_event` code like `ON_WEB_ORDER`, `INITIATE_ORDER`, `ON_WEB_REGISTER`, `FORM`); to inherit a pixel + event from an existing ad group, pass `adSetId` instead. LinkedIn-specific: `engagement`, `traffic`, `awareness`, and `video_views` are supported for standalone ads (creates a Direct Sponsored Content single image or single video ad). `traffic` requires `linkUrl`; `video_views` requires the `video` field. For `lead_generation` / `conversions` on LinkedIn — or to promote an existing post — use `POST /v1/ads/boost`. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **optimizationGoal** `string`: Meta only. Explicit ad-set `optimization_goal` (e.g. `LANDING_PAGE_VIEWS`, `LINK_CLICKS`, `REACH`, `IMPRESSIONS`, `OFFSITE_CONVERSIONS`, `THRUPLAY`, `LEAD_GENERATION`). Overrides the default derived from `goal` (e.g. `traffic` defaults to `LINK_CLICKS`). Forwarded verbatim to Meta, which validates compatibility with the campaign objective and rejects incompatible combinations. - **budgetAmount** `number`: Required on legacy + multi-creative shapes. Inherited on attach. - **budgetType** `string`: Required on legacy + multi-creative shapes. Inherited on attach. - one of: daily, lifetime - **budgetLevel** `string`: Meta only. Where the budget lives, which selects the Meta budget model: - `adset` (default): ABO (Ad-set Budget Optimization). The budget is set on the ad set. This is the back-compatible behaviour — omit this field to keep it. - `campaign`: CBO (Campaign Budget Optimization / Advantage Campaign Budget). The budget AND `bidStrategy` are set on the CAMPAIGN, and Meta distributes spend across ad sets automatically. Meta requires the budget at exactly one level, never both. Non-Meta platforms ignore this field. Ignored on the attach shape (`adSetId`), which inherits the existing budget. - one of: adset, campaign - **currency** `string`: No description - **headline** `string`: Required for Meta, Google, Pinterest, and LinkedIn on legacy + attach shapes (skip for multi-creative — use `creatives[].headline`). Ignored for TikTok and X/Twitter. Max: Meta=255, Google=30, Pinterest=100, LinkedIn=400. On LinkedIn this is the ad's headline (the bold text on the creative); for traffic ads it's the link card title. - **longHeadline** `string`: Google Display only — defaults to `headline` if omitted. On LinkedIn, reused as the optional secondary description text on traffic (link) ads; omitted if not provided. - **body** `string`: Required on legacy + attach shapes. For X/Twitter this is the tweet text (max 280 chars including a ~24-char URL when `linkUrl` is set). On LinkedIn this is the post commentary (the intro text shown above the ad). Max: Google=90, Pinterest=500. - **description** `string`: Meta only (facebook/instagram). Link description — the secondary text shown below the headline (Meta's link_data.description; on video creatives mapped to video_data.link_description). When omitted, Meta auto-pulls the destination URL's OpenGraph description. Applies on legacy, attach, and placementAssets shapes; for multi-creative use creatives[].description (this field is the shared fallback). For multi-text variations use dynamicCreative.descriptions instead. - **callToAction** `string`: Required on legacy + attach shapes for Meta. Honoured on TikTok (passes through to the Spark Ad creative's `call_to_action`) and on LinkedIn (the CTA button on the ad; defaults to LEARN_MORE when `linkUrl` is set). LinkedIn accepts: LEARN_MORE, SIGN_UP, DOWNLOAD, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, SHOP_NOW, BUY_NOW. Ignored by Google, Pinterest, and X/Twitter. - one of: LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW - **linkUrl** `string`: Required on legacy + attach shapes (skip for multi-creative). On LinkedIn it's the ad's destination URL; required for `traffic` ads, optional for `engagement` / `awareness`. NOT required when `goal` is `lead_generation` (the ad opens a Lead Gen form instead of a destination). - **leadGenFormId** `string`: Meta Lead Gen forms only (facebook/instagram). The leadgen_forms ID to attach to the ad's creative — create one via POST /v1/ads/lead-forms. REQUIRED when `goal` is `lead_generation`, and on every ATTACH (`adSetId`) call that targets a lead ad set (the form attaches per-ad; Meta rejects a formless ad in a lead ad set). Ignored otherwise. The ad set's promoted_object.page_id + LEAD_GENERATION optimization + destination_type ON_AD are derived automatically from the goal. Both `placementAssets` (per-placement creative) and `dynamicCreative` (multi-text / multi-asset pool, e.g. multiple headlines and primary texts) ARE supported on instant-form lead ads — the form is attached for you, and for `dynamicCreative` the ad set is created as a Dynamic Creative ad set automatically (Meta requires that for any multi-text feed; there is no non-DCO multi-text path). Send a single `imageUrls` entry plus your text variations to get Meta's "Multiple Text Options" behavior on a lead ad. - **imageUrl** `string`: Image creative for Meta/Google/Pinterest/LinkedIn on legacy + attach shapes (mutually exclusive with `video`). Required for LinkedIn ads unless `video` is set. Not required for Google Search campaigns. For TikTok, this field carries the VIDEO URL (the TikTok ads endpoint is video-only; the field retains the `imageUrl` name for cross-platform consistency). Ignored for X/Twitter. For Google Display, treated as the landscape image (alias of `images.landscape`); supply `images.square` alongside or the request is rejected. For LinkedIn the image is uploaded to LinkedIn under the authoring Company Page (see `organizationId`); recommended ratio 1.91:1 (e.g. 1200×627). - **images** `object`: 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. - **video** `object`: Meta (facebook, instagram) and LinkedIn. When set, creates a VIDEO ad on the legacy (or, for Meta, attach) shape. Mutually exclusive with `imageUrl`. For Meta multi-creative, set `video` per entry inside `creatives[]` instead. For LinkedIn the video is uploaded to LinkedIn under the authoring Company Page (see `organizationId`) and the campaign format is set to SINGLE_VIDEO; LinkedIn ignores `thumbnailUrl` (it auto-generates the poster frame) — supply MP4 H.264/AAC, 3s-30min, 75KB-500MB. - **creatives** `array`: 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`. - **adSetId** `string`: 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[]`. The attached ad takes the full single-creative surface: `headline`/`body`/`description`/`callToAction` plus either `imageUrl`/`video` OR `placementAssets` (its own per-placement Feed/Story assets), and `leadGenFormId` when the target is a lead ad set (the parent must be ON_AD — true for ad sets created via goal `lead_generation`; Meta rejects a formless ad there, so pass the form on EVERY attached ad). This is the way to build N full ads sharing one ad set: create the first ad via the normal shape, then attach the rest one call each. Supported on Meta (facebook, instagram) and TikTok. On TikTok the `adSetId` is the ad group ID; the new ad inherits the ad group's bid + budget + targeting. - **businessName** `string`: Google Display only - **boardId** `string`: Pinterest only. Board ID (auto-creates if not provided). - **organizationId** `string`: LinkedIn only. The Company Page that authors the Direct Sponsored Content ("dark") post backing the ad — accepts a numeric organization ID or a full `urn:li:organization:N` URN. Required unless the resolved `accountId` is a connected LinkedIn Company-Page account (defaults to that page) or the LinkedIn ad account is org-owned (defaults to the account's owning organization). The authenticated member must be an ADMINISTRATOR or DIRECT_SPONSORED_CONTENT_POSTER of this page (and the page must be associated with the ad account), or LinkedIn returns 403. Ignored by every other platform. - **countries** `array`: ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no `cities` or `regions` are provided. (LinkedIn currently honours country-level targeting only.) - **cities** `array`: 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=&country_code=`. 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. - **regions** `array`: 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=&country_code=`. - **ageMin** `integer`: No description - **ageMax** `integer`: No description - **interests** `array`: Interest objects from /v1/ads/interests. Each must include id and name. - **zips** `array`: Postal/ZIP geo targeting. `key` is the platform's postal location ID from /v1/ads/targeting/search?dimension=geo&geoType=zip. Supported on Meta, Google, TikTok, Pinterest, X. - **metros** `array`: DMA / metro-area geo targeting. `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro. - **customLocations** `array`: Point-radius (lat/lng) geo targeting. Meta only (custom_locations). Rejected on platforms without radius support. - **behaviors** `array`: Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok. Each must include id. - **incomeTier** `string`: Normalized household-income tier. Meta and TikTok express all four; Google maps only `top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible with housing/employment/credit `specialAdCategories`. - one of: top_5, top_10, top_10_25, top_25_50 - **languages** `array`: Language codes (e.g. ['en']). Restricts the audience by language. - **placements** `object`: Meta only. Manual ad placements. Omit for automatic placements (Meta's default, recommended for most cases — Meta optimises delivery across all eligible surfaces). When set, restricts delivery to the chosen surfaces, mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions, messenger_positions, audience_network_positions, threads_positions, whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta additionally enforces co-selection rules (e.g. some positions require their parent publisher platform) and returns an actionable error which we surface. Non-Meta platforms reject this field. - **savedTargetingId** `string`: ID of a `saved_targeting` audience (created via POST /v1/ads/audiences). When set, its stored TargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you reuse a named targeting preset without re-sending every field. - **rawTargeting** `object`: Meta only. A raw Meta-native targeting spec passed to the ad set VERBATIM (snake_case: `geo_locations`, `age_min`, `excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, business places, etc.) — exactly the shape `GET /v1/ads/{adId}` returns for external ads. Use it to clone a campaign's targeting EXACTLY, preserving advanced fields the camelCase targeting fields can't model. Mutually exclusive with the camelCase targeting fields (countries/regions/cities/interests/ ageMin/...), `audienceId`, and `savedTargetingId` (sending both → 422). Sent as-is; Meta validates and surfaces any errors. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those are separate fields, not part of targeting). - **specialAdCategories** `array`: Meta only. Declares the ad's special category, required for housing, employment, credit, or political/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special category disables income/zip targeting on Meta. - **endDate** `string`: Required for lifetime budgets - **startDate** `string`: Meta only. Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the ad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime budgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already available on `POST /v1/ads/boost`.) - **instagramAccountId** `string`: Meta only. Override the Instagram account the ad is delivered as — pass an Instagram Business Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`. When omitted we auto-resolve the IG account linked to the connected Facebook Page (the existing default). Useful when a Page has more than one eligible IG account. - **dynamicCreative** `object`: Meta only. Dynamic Creative: supply a POOL of assets and Meta auto-combines and optimises them into the best-performing variations within a single ad (mapped to the creative's `asset_feed_spec`). When set, the top-level single-creative fields (`imageUrl`, `headline`, `body`, `linkUrl`, `callToAction`) are ignored. Mutually exclusive with the `creatives[]` multi-creative shape. Meta limits: ≤10 images, ≤5 bodies / titles / descriptions. - **placementAssets** `object`: Meta only. Placement asset customization: pin a SPECIFIC asset (image OR video) to each placement group on a SINGLE ad (e.g. a 9:16 on Stories/Reels and a 4:5 on Feed). The same thing Meta Ads Manager produces with "different creative per placement", mapped to the creative's `asset_feed_spec` + `asset_customization_rules`. Deterministic pinning, NOT the auto-optimizing pool of `dynamicCreative` (mutually exclusive). Works on the legacy single shape AND the attach shape (`adSetId` + placementAssets adds one placement-customized ad to an existing ad set — the way to build N per-placement ads sharing one ad set: create the first normally, attach the rest). Cannot be combined with `creatives[]`. Shared copy (headline, body, link, CTA) comes from the top-level single-creative fields since only the asset varies by placement. Each rule's `placements` accepts the same fields as the top-level `placements` object; Meta enforces co-selection rules and returns an actionable error. A block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad format). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode: `defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl` posters; Meta auto-generates when omitted). Exactly one catch-all default is required. - **audienceId** `string`: Custom audience ID for targeting - **campaignType** `string`: Google only - one of: display, search - **keywords** `array`: Google Search only - **additionalHeadlines** `array`: Google Search RSA only. Extra headlines. - **additionalDescriptions** `array`: Google Search RSA only. Extra descriptions. - **advantageAudience** `integer`: 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. - one of: 0, 1 - **attributionSpec** `array`: Meta only. Conversion attribution window for the ad set — maps 1:1 to Meta's ad-set `attribution_spec`. Only honored for conversion goals (`conversions`, `lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement. Omit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the valid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view windows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside `VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos surface as a Meta 400. Example: `[{ "eventType": "CLICK_THROUGH", "windowDays": 7 }, { "eventType": "VIEW_THROUGH", "windowDays": 1 }]` - **gender** `string`: Meta only. Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Ignored by non-Meta platforms. - one of: all, male, female - **bidStrategy**: Meta bid strategy applied to the ad set. - **bidAmount** `number`: 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** `number`: 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** `string`: 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** `string`: 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** `object`: 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. - **identityType** `string`: 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. - one of: TT_USER, CUSTOMIZED_USER - **promotedObject** `object`: What the ad optimises against. Behaviour depends on the platform. **Meta**: forwarded to the ad set's `promoted_object` (snake-cased). Required for goals whose ad-set optimization_goal points at a specific event/page/app (without it Meta rejects the ad-set create with `error_subcode: 1815430` "Please select a promoted object for your ad set"): - `goal: conversions` (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 Meta goals (engagement, traffic, awareness, video_views) ignore this field. **TikTok**: only `goal: conversions` uses it. - `pixelId` maps to the ad group's `pixel_id`. Required: a TikTok website-conversion ad group without a pixel is rejected with `40002: Please select a pixel`. - `customEventType` maps to the ad group's `optimization_event` (the pixel event to optimise for). Optional: TikTok accepts a pixel-only auto-bid conversion ad group. See the `customEventType` field below for the valid TikTok codes. The remaining `promotedObject.*` fields are Meta-only. Platforms other than Meta and TikTok ignore `promotedObject` entirely. ### Responses #### 201: Ad(s) created **Response Body:** *One of the following:* - **ad**: `Ad` - See schema definition - **message** `string`: No description - **ads** `array[Ad]`: - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **message** `string`: No description #### 400: Missing required fields, invalid values, or non-Meta platform used with creatives[] / adSetId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 422: Platform ads connection required (TikTok Ads, X Ads) or missing linked account --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Create a synthetic test lead API Reference Submits a test lead against the form (POST /{form-id}/test_leads) to exercise retrieval without waiting for real ad impressions. Meta allows one test lead per form at a time. ## POST /v1/ads/lead-forms/{formId}/test-leads **Create a synthetic test lead** Submits a test lead against the form (POST /{form-id}/test_leads) to exercise retrieval without waiting for real ad impressions. Meta allows one test lead per form at a time. ### Parameters - **formId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **fieldData** (required) `array`: No description ### Responses #### 200: Test lead created. **Response Body:** - **status** `string`: No description - **testLead** `object`: - **id** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Cancel an ad API Reference Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history. ## GET /v1/ads/{adId} **Get ad details** Returns an ad with its creative, targeting, status, and performance metrics. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: - the Zernio internal `_id` (24-char hex) - Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`) - the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side) - the creative's `effective_instagram_media_id` (Instagram side) Any of the four resolve to the same ad. Caller doesn't need a translation step. ### Parameters - **adId** (required) in path: Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details. ### Responses #### 200: Ad details **Response Body:** - **ad**: `Ad` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/ads/{adId} **Update ad** 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`. ### Parameters - **adId** (required) in path: No description ### Request Body - **status** `string`: No description - one of: active, paused - **budget** `object`: No description - **targeting** `object`: Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501. - **creative** `object`: 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. - **name** `string`: Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501. ### Responses #### 200: Ad updated **Response Body:** - **ad**: `Ad` - See schema definition - **message** `string`: No description #### 400: Invalid status transition or budget below minimum #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 501: targeting or creative not supported on the platform (Meta + TikTok only) --- ## DELETE /v1/ads/{adId} **Cancel an ad** Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history. ### Parameters - **adId** (required) in path: No description ### Responses #### 200: Ad cancelled **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Soft-delete a conversion destination API Reference LinkedIn-only today. LinkedIn does not expose hard-delete on conversion rules — what their UI calls "delete" is the same `enabled: false` flip we apply here. The rule remains fetchable via GET with `status: 'inactive'`; the unified discovery endpoint hides it by default. `adAccountId` may be passed as a query parameter (recommended) or as a JSON body field for clients that can send DELETE bodies. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Fetch a single conversion destination** LinkedIn-only today. Returns the full destination record for one conversion rule. The `adAccountId` query parameter is required because LinkedIn rules are scoped to a sponsored ad account. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN. ### Responses #### 200: Destination fetched **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support fetching a single destination. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## PATCH /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Update a conversion destination** Partial-update a conversion rule. LinkedIn-only today. Whitelisted fields: `name`, `enabled`, attribution windows, `valueType`, `value`, `attributionType`. The rule's `type` and parent ad account are intentionally not exposed for update — recreate the rule if those need to change. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **name** `string`: No description - **enabled** `boolean`: Setting `false` is equivalent to calling DELETE — the rule will appear as `inactive` afterwards. - **attributionType** `string`: No description - one of: LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION - **postClickAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **viewThroughAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **valueType** `string`: No description - one of: DYNAMIC, FIXED, NO_VALUE - **value** `object`: Used when `valueType=FIXED`. ### Responses #### 200: Destination updated (re-fetched canonical state) **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Invalid body or LinkedIn validation failure. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support updating destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Soft-delete a conversion destination** LinkedIn-only today. LinkedIn does not expose hard-delete on conversion rules — what their UI calls "delete" is the same `enabled: false` flip we apply here. The rule remains fetchable via GET with `status: 'inactive'`; the unified discovery endpoint hides it by default. `adAccountId` may be passed as a query parameter (recommended) or as a JSON body field for clients that can send DELETE bodies. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (optional) in query: Required as query OR in JSON body. ### Responses #### 204: Soft-deleted. #### 400: adAccountId missing. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support deleting destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- # Related Schema Definitions ## ConversionDestination A discoverable conversion destination on an ad platform — a Meta pixel, Google conversion action, or LinkedIn conversion rule. Returned by `listConversionDestinations`, `getConversionDestination`, `createConversionDestination`, and `updateConversionDestination`. ### Properties - **id** (required) `string`: Platform-native identifier. Pass back as `destinationId` on event send and as the path segment on CRUD endpoints. - **name** (required) `string`: No description - **type** `string`: Present when the platform locks the event type/category to the destination (Google conversion actions, LinkedIn conversion rules). Absent for Meta pixels (which accept any event name per request). - **status** `string`: For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`). - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls to identify the parent ad account. --- # Estimate audience reach API Reference Returns a normalized pre-flight audience-size estimate for a targeting spec, before any campaign is created. Backed by each platform's native reach API (Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`, Pinterest `audience_sizing`). Platforms without a usable pre-flight reach API (Google Search/Display, TikTok) return `available: false` with no bounds, so clients can hide or grey out the estimate rather than treat the absence as an error. ## POST /v1/ads/targeting/reach-estimate **Estimate audience reach** Returns a normalized pre-flight audience-size estimate for a targeting spec, before any campaign is created. Backed by each platform's native reach API (Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`, Pinterest `audience_sizing`). Platforms without a usable pre-flight reach API (Google Search/Display, TikTok) return `available: false` with no bounds, so clients can hide or grey out the estimate rather than treat the absence as an error. ### Request Body - **accountId** (required) `string`: Zernio social account ID on the target ad platform (the estimate runs against its platform). - **adAccountId** (required) `string`: Required. The platform ad-account ID the reach call runs against (Meta act_..., LinkedIn numeric sponsoredAccount ID, Pinterest ad-account ID, X account ID) - every backing reach API is scoped to one ad account. Get it from GET /v1/ads/accounts. - **spec** (required): The targeting spec to estimate. Same shape used by POST /v1/ads/create. - **optimizationGoal** `string`: Optional. The optimization goal the estimate should assume (platform's own vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`). Some platforms vary the estimate by goal; omit to use the platform default. ### Responses #### 200: Normalized reach estimate **Response Body:** - **available** (required) `boolean`: Whether a pre-flight estimate is available on this platform. False for Google and TikTok. - **lower** `integer`: Lower bound of the estimated reachable audience. Present only when available. - **upper** `integer`: Upper bound of the estimated reachable audience. Present only when available. - **daily** `integer`: Optional estimated daily reach/results at the given budget, when the platform returns it. - **currency** `string`: Currency of any monetary fields in the estimate, when applicable. - **estimateReady** `boolean`: Meta only. False when Meta is still computing the estimate (the audience is too new); retry shortly. #### 400: Missing required fields or a targeting field the platform cannot honour #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Get ad analytics API Reference Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline over the requested date range, and optional demographic breakdowns (Meta and TikTok only). If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. ## GET /v1/ads/{adId}/analytics **Get ad analytics** Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline over the requested date range, and optional demographic breakdowns (Meta and TikTok only). If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. ### Parameters - **adId** (required) in path: No description - **fromDate** (optional) in query: Start of date range (YYYY-MM-DD). Defaults to 90 days ago. - **toDate** (optional) in query: End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range. - **breakdowns** (optional) in query: Comma-separated breakdown dimensions. Meta: age, gender, country, publisher_platform, device_platform, region. TikTok: gender, age, country_code, platform, ac, language. ### Responses #### 200: Ad analytics **Response Body:** - **ad** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **status** `string`: No description - **currency** `string`: ISO 4217 code of the ad account that owns this ad (e.g. USD, THB, INR). All money values in `summary` and `daily` are in this currency. Null only on legacy ads synced before currency was persisted. - **analytics** `object`: - **summary**: `AdMetrics` - See schema definition - **daily** `array[items]`: - **breakdowns** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## AdMetrics ### Properties - **spend** `number`: No description - **impressions** `integer`: No description - **reach** `integer`: No description - **clicks** `integer`: No description - **ctr** `number`: Click-through rate (%) - **cpc** `number`: Cost per click - **cpm** `number`: Cost per 1000 impressions - **engagement** `integer`: No description - **conversions** `integer`: 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** `number`: Derived spend / conversions in the same currency as spend. 0 when conversions is 0. - **actions** `object`: 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. - **actionValues** `object`: 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 {}. - **purchaseValue** `number`: 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** `number`: 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** `string`: Present on individual ads only, not on campaign aggregations --- # List comments on an ad API Reference Returns comments on an ad's underlying creative post. Useful for moderating or analyzing engagement on dark posts (ad creatives that never went live organically), which the regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are not in Zernio's post database. An ad that runs on both Facebook feed and Instagram feed has two separate underlying posts with separate comment threads (the creative's effective_object_story_id and effective_instagram_media_id). Use the `placement` query param to pick one; with no param the Instagram side is returned when it exists, otherwise Facebook. The identifiers are read from the ad record (persisted during sync) with a Marketing-API fallback for ads that predate the field. For Instagram-placed comments, the Instagram account that runs the ad must be connected to Zernio — those comments are read through that account's token. If no connected Instagram account on the profile can read the ad's media, the call returns ads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook). Meta-only. Other ad platforms (TikTok, LinkedIn, Pinterest, Google, X) do not expose a public per-ad comments API and return feature_not_available. Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: Zernio internal `_id` (24-char hex), Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`), or the creative's `effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a translation step. ## GET /v1/ads/{adId}/comments **List comments on an ad** Returns comments on an ad's underlying creative post. Useful for moderating or analyzing engagement on dark posts (ad creatives that never went live organically), which the regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are not in Zernio's post database. An ad that runs on both Facebook feed and Instagram feed has two separate underlying posts with separate comment threads (the creative's effective_object_story_id and effective_instagram_media_id). Use the `placement` query param to pick one; with no param the Instagram side is returned when it exists, otherwise Facebook. The identifiers are read from the ad record (persisted during sync) with a Marketing-API fallback for ads that predate the field. For Instagram-placed comments, the Instagram account that runs the ad must be connected to Zernio — those comments are read through that account's token. If no connected Instagram account on the profile can read the ad's media, the call returns ads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook). Meta-only. Other ad platforms (TikTok, LinkedIn, Pinterest, Google, X) do not expose a public per-ad comments API and return feature_not_available. Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: Zernio internal `_id` (24-char hex), Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`), or the creative's `effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a translation step. ### Parameters - **adId** (required) in path: Internal Zernio ad ID (ObjectId). - **placement** (optional) in query: Which side of the ad to return comments for. Omit to default to the Instagram side when present, else Facebook. Returns ad_not_commentable if the ad has no such placement. - **limit** (optional) in query: No description - **cursor** (optional) in query: Pagination cursor from a previous response. ### Responses #### 200: Comments on the ad **Response Body:** - **status** (required) `string`: No description - one of: success - **comments** (required) `array[object]`: Type: `object` - **pagination** (required) `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description - **meta** (required) `object`: - **platform** (required) `string`: Which side these comments are on (same as `placement`). - one of: facebook, instagram - **placement** (required) `string`: The placement these comments are for — useful when you didn't pass ?placement= and want to know which one you got. - one of: facebook, instagram - **adId** (required) `string`: Internal Zernio ad ID. - **platformAdId** (required) `string`: Meta ad ID. - **effectiveStoryId** (required) `string`: Underlying post ID the comments belong to. effective_object_story_id for the Facebook side, effective_instagram_media_id for the Instagram side. - **facebookAccountId** `string`: Facebook-only. The connected Facebook Page SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete. Null when no connected Page was used (then moderation isn't possible). - **instagramUserId** `string`: Instagram-only. The Instagram-scoped business ID that owns the boosted media (creative.instagram_user_id). - **instagramPermalink** `string`: Instagram-only. Public permalink of the boosted IG post (creative.instagram_permalink_url). - **instagramAccountId** `string`: Instagram-only. The connected Instagram SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete. - **accountId** (required) `string`: Social account ID (ads SocialAccount). - **lastUpdated** (required) `string` (date-time): No description #### 400: Invalid ad ID format, or the ad's creative format does not expose a commentable underlying post (code ad_not_commentable). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (legacy plans need the Ads add-on; included by default on usage-based plans), or ad platform is not Meta (code feature_not_available). #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 422: Ads account token unavailable, or (for Instagram-placed ads) no connected Instagram account on the profile can read the ad's media (code ads_connection_required). --- --- # Read an ad's click-URL tracking tags API Reference Unified read of the platform's native click-URL tracking params. - Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec). - Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`. Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access. - LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`. Returns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest). ## GET /v1/ads/{adId}/tracking-tags **Read an ad's click-URL tracking tags** Unified read of the platform's native click-URL tracking params. - Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec). - Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`. Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access. - LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`. Returns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest). ### Parameters - **adId** (required) in path: Ad id (hex _id, platformAdId, or effective story/media id). ### Responses #### 200: Tracking tags for the ad's platform (shape varies by platform). **Response Body:** - **platform** `string`: No description - **level** `string`: No description - one of: creative, campaign - **urlTags** `string`: Meta: &-joined click-URL params. - **templateUrlSpec** `object`: Meta: third-party click-tracking template (Dynamic Ads). - **trackingUrlTemplate** `string`: Google. - **finalUrlSuffix** `string`: Google. - **dynamicValueParameters** `object`: LinkedIn. - **customValueParameters** `object`: LinkedIn. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad not found #### 405: Platform has no click-URL tracking surface --- ## PATCH /v1/ads/{adId}/tracking-tags **Set/update an ad's click-URL tracking tags** Unified update. Send only the fields for the ad's platform: - Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim (re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags` ALONE — no need to read back headline/body/CTA. `creative` (headline, body, callToAction, linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post / dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for `creative`). - Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies). - LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM). ### Parameters - **adId** (required) in path: No description ### Request Body - **urlTags** `array`: Meta only. Click-URL params appended to a freshly-rebuilt creative. - **creative** `object`: Meta only. OPTIONAL — omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips. - **trackingUrlTemplate** `string`: Google only. Full tracking template (must contain {lpurl}). - **finalUrlSuffix** `string`: Google only. Parse-only key=value params. - **dynamicValueParameters** `object`: LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...). - **customValueParameters** `object`: LinkedIn only. key -> static value. ### Responses #### 200: Updated #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad not found #### 405: Platform has no click-URL tracking surface #### 422: Meta creative cannot be rebuilt (e.g. placement-customized/asset-feed/dark creative) --- --- # Get ad details API Reference Returns an ad with its creative, targeting, status, and performance metrics. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: - the Zernio internal `_id` (24-char hex) - Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`) - the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side) - the creative's `effective_instagram_media_id` (Instagram side) Any of the four resolve to the same ad. Caller doesn't need a translation step. ## GET /v1/ads/{adId} **Get ad details** Returns an ad with its creative, targeting, status, and performance metrics. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: - the Zernio internal `_id` (24-char hex) - Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`) - the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side) - the creative's `effective_instagram_media_id` (Instagram side) Any of the four resolve to the same ad. Caller doesn't need a translation step. ### Parameters - **adId** (required) in path: Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details. ### Responses #### 200: Ad details **Response Body:** - **ad**: `Ad` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/ads/{adId} **Update ad** 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`. ### Parameters - **adId** (required) in path: No description ### Request Body - **status** `string`: No description - one of: active, paused - **budget** `object`: No description - **targeting** `object`: Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501. - **creative** `object`: 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. - **name** `string`: Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501. ### Responses #### 200: Ad updated **Response Body:** - **ad**: `Ad` - See schema definition - **message** `string`: No description #### 400: Invalid status transition or budget below minimum #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 501: targeting or creative not supported on the platform (Meta + TikTok only) --- ## DELETE /v1/ads/{adId} **Cancel an ad** Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history. ### Parameters - **adId** (required) in path: No description ### Responses #### 200: Ad cancelled **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Fetch a single conversion destination API Reference LinkedIn-only today. Returns the full destination record for one conversion rule. The `adAccountId` query parameter is required because LinkedIn rules are scoped to a sponsored ad account. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Fetch a single conversion destination** LinkedIn-only today. Returns the full destination record for one conversion rule. The `adAccountId` query parameter is required because LinkedIn rules are scoped to a sponsored ad account. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN. ### Responses #### 200: Destination fetched **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support fetching a single destination. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## PATCH /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Update a conversion destination** Partial-update a conversion rule. LinkedIn-only today. Whitelisted fields: `name`, `enabled`, attribution windows, `valueType`, `value`, `attributionType`. The rule's `type` and parent ad account are intentionally not exposed for update — recreate the rule if those need to change. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **name** `string`: No description - **enabled** `boolean`: Setting `false` is equivalent to calling DELETE — the rule will appear as `inactive` afterwards. - **attributionType** `string`: No description - one of: LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION - **postClickAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **viewThroughAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **valueType** `string`: No description - one of: DYNAMIC, FIXED, NO_VALUE - **value** `object`: Used when `valueType=FIXED`. ### Responses #### 200: Destination updated (re-fetched canonical state) **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Invalid body or LinkedIn validation failure. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support updating destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Soft-delete a conversion destination** LinkedIn-only today. LinkedIn does not expose hard-delete on conversion rules — what their UI calls "delete" is the same `enabled: false` flip we apply here. The rule remains fetchable via GET with `status: 'inactive'`; the unified discovery endpoint hides it by default. `adAccountId` may be passed as a query parameter (recommended) or as a JSON body field for clients that can send DELETE bodies. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (optional) in query: Required as query OR in JSON body. ### Responses #### 204: Soft-deleted. #### 400: adAccountId missing. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support deleting destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- # Related Schema Definitions ## ConversionDestination A discoverable conversion destination on an ad platform — a Meta pixel, Google conversion action, or LinkedIn conversion rule. Returned by `listConversionDestinations`, `getConversionDestination`, `createConversionDestination`, and `updateConversionDestination`. ### Properties - **id** (required) `string`: Platform-native identifier. Pass back as `destinationId` on event send and as the path segment on CRUD endpoints. - **name** (required) `string`: No description - **type** `string`: Present when the platform locks the event type/category to the destination (Google conversion actions, LinkedIn conversion rules). Absent for Meta pixels (which accept any event name per request). - **status** `string`: For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`). - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls to identify the parent ad account. --- # Fetch attribution metrics for a conversion destination API Reference LinkedIn-only today. Returns conversion-attribution metrics (`externalWebsiteConversions`, `externalWebsitePostClickConversions`, `externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`, `qualifiedLeads`, `costInLocalCurrency`) bucketed by date. Date-range constraints (passed through from LinkedIn): - `granularity=DAILY` is retained for ~6 months only - `granularity=ALL` with a range > 6 months auto-rounds to month boundaries - `granularity=MONTHLY`/`YEARLY` retains 24 months Throttle: LinkedIn caps adAnalytics at 45M metric values per 5-minute window across the calling token. Single-rule queries are well within that limit; surfaces as 429 if hit. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/metrics **Fetch attribution metrics for a conversion destination** LinkedIn-only today. Returns conversion-attribution metrics (`externalWebsiteConversions`, `externalWebsitePostClickConversions`, `externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`, `qualifiedLeads`, `costInLocalCurrency`) bucketed by date. Date-range constraints (passed through from LinkedIn): - `granularity=DAILY` is retained for ~6 months only - `granularity=ALL` with a range > 6 months auto-rounds to month boundaries - `granularity=MONTHLY`/`YEARLY` retains 24 months Throttle: LinkedIn caps adAnalytics at 45M metric values per 5-minute window across the calling token. Single-rule queries are well within that limit; surfaces as 429 if hit. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description - **startDate** (required) in query: No description - **endDate** (optional) in query: No description - **granularity** (optional) in query: No description ### Responses #### 200: Metrics rows **Response Body:** - **platform** `string`: No description - one of: linkedinads - **granularity** `string`: No description - one of: ALL, DAILY, MONTHLY, YEARLY - **rows** `array[object]`: - **start** `string`: YYYY-MM-DD - **end** `string`: YYYY-MM-DD (inclusive) - **metrics** `object`: No description #### 400: Validation error or invalid date range. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support metrics readback. #### 429: LinkedIn analytics rate limit hit. --- --- # Read Event Match Quality + coverage for a Meta pixel API Reference Reads Meta Event Match Quality (EMQ) and pixel↔CAPI event coverage for a pixel/dataset, live from Meta's Dataset Quality API. Web events only (a Meta limitation). Meta-only; other platforms return 405. Requires the Ads add-on. ## GET /v1/ads/conversions/quality **Read Event Match Quality + coverage for a Meta pixel** Reads Meta Event Match Quality (EMQ) and pixel↔CAPI event coverage for a pixel/dataset, live from Meta's Dataset Quality API. Web events only (a Meta limitation). Meta-only; other platforms return 405. Requires the Ads add-on. ### Parameters - **accountId** (required) in query: SocialAccount _id (must be a metaads account). - **destinationId** (required) in query: Meta pixel/dataset ID. ### Responses #### 200: Match-quality rows, one per event name. **Response Body:** - **platform** `string`: No description (example: "metaads") - **rows** `array[object]`: - **eventName** `string`: No description - **compositeScore** `number`: Composite EMQ score, 0-10. - **matchKeys** `array[object]`: - **identifier** `string`: No description - **coveragePercentage** `number`: No description - **eventCoveragePercentage** `number`: Pixel↔CAPI coverage rate for this event. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 405: Platform does not expose Event Match Quality (non-Meta). --- --- # Get a single Lead Gen form API Reference ## GET /v1/ads/lead-forms/{formId} **Get a single Lead Gen form** ### Parameters - **formId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Form metadata. **Response Body:** - **status** `string`: No description - **form** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## DELETE /v1/ads/lead-forms/{formId} **Archive a Lead Gen form** Meta has no hard delete for forms; this archives the form (status=ARCHIVED). ### Parameters - **formId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Archived. **Response Body:** - **status** `string`: No description - **formId** `string`: No description - **archived** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # List ad accounts API Reference 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. ## GET /v1/ads/accounts **List ad accounts** 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. ### Parameters - **accountId** (required) in query: Social account ID - **adAccountId** (optional) in query: Filter response to a single platform ad account ID (e.g. `act_123` for Meta, advertiser_id for TikTok). Returns at most one item. - **limit** (optional) in query: Clamp the returned `accounts[]` length. Useful for typeahead pickers on agency tokens with hundreds of advertisers. ### Responses #### 200: Ad accounts **Response Body:** - **accounts** `array[object]`: - **id** `string`: Platform ad account ID (e.g. act_123) - **name** `string`: No description - **currency** `string`: No description - **status** `string`: No description - **timezoneName** `string`: IANA timezone of the ad account (Meta only). Drives daily-budget reset and Insights day boundaries. - **timezoneOffsetHoursUtc** `number`: Signed UTC offset in hours, reflecting current DST (Meta only). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 422: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account --- --- # List a catalog's product sets API Reference Lists a Meta product catalog's product sets — the unit a catalog ad promotes. Pass the chosen set as `promotedObject.productSetId` on POST /v1/ads/create with `goal: catalog_sales`. ## GET /v1/ads/catalogs/{catalogId}/product-sets **List a catalog's product sets** Lists a Meta product catalog's product sets — the unit a catalog ad promotes. Pass the chosen set as `promotedObject.productSetId` on POST /v1/ads/create with `goal: catalog_sales`. ### Parameters - **catalogId** (required) in path: Meta product catalog ID (from GET /v1/ads/catalogs) - **accountId** (required) in query: A facebook, instagram, or metaads social account ID ### Responses #### 200: Product sets **Response Body:** - **productSets** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **productCount** `integer`: No description --- --- # List Meta product catalogs API Reference Lists the Meta product catalogs reachable from an ad account (owned + agency-shared catalogs of the ad account's business), for Advantage+ catalog ads (`goal: catalog_sales` on POST /v1/ads/create — e.g. vehicle inventory catalogs). Read-only; uses scopes customers already granted (no reconnect needed). Catalog contents (items, feeds) are managed in Meta Commerce Manager, not through this API. ## GET /v1/ads/catalogs **List Meta product catalogs** Lists the Meta product catalogs reachable from an ad account (owned + agency-shared catalogs of the ad account's business), for Advantage+ catalog ads (`goal: catalog_sales` on POST /v1/ads/create — e.g. vehicle inventory catalogs). Read-only; uses scopes customers already granted (no reconnect needed). Catalog contents (items, feeds) are managed in Meta Commerce Manager, not through this API. ### Parameters - **accountId** (required) in query: A facebook, instagram, or metaads social account ID - **adAccountId** (required) in query: Meta ad account ID (act_...) ### Responses #### 200: Catalogs **Response Body:** - **catalogs** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **vertical** `string`: Catalog vertical (e.g. commerce, vehicles, hotels) - **productCount** `integer`: No description --- --- # List TikTok Business Centers API Reference 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. ## GET /v1/ads/business-centers **List TikTok Business Centers** 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. ### Parameters - **accountId** (required) in query: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount ### Responses #### 200: Business centers **Response Body:** - **businessCenters** `array[BusinessCenter]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: TikTok account not found #### 422: TikTok Ads not connected --- # Related Schema Definitions ## BusinessCenter 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** `string`: Business Center ID - **name** `string`: Display name set by the BC owner - **advertiserCount** `integer`: 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. --- # List ads API Reference Returns a paginated list of ads with metrics computed over an optional date range. Use source=all to include externally-synced ads from platform ad managers. If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. To find the Zernio ad behind a comment you see in Meta Business Manager, filter by platformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or effectiveInstagramMediaId (Instagram) — those are the post/media the ad's engagement lives on, and are also returned on each ad's `creative` object. Then call GET /v1/ads/{adId}/comments with the returned ad id. ## GET /v1/ads **List ads** Returns a paginated list of ads with metrics computed over an optional date range. Use source=all to include externally-synced ads from platform ad managers. If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max. To find the Zernio ad behind a comment you see in Meta Business Manager, filter by platformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or effectiveInstagramMediaId (Instagram) — those are the post/media the ad's engagement lives on, and are also returned on each ad's `creative` object. Then call GET /v1/ads/{adId}/comments with the returned ad id. ### Parameters - **undefined** (optional): No description - **limit** (optional) in query: No description - **source** (optional) in query: all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only. - **status** (optional) in query: No description - **platform** (optional) in query: No description - **accountId** (optional) in query: Social account ID - **adAccountId** (optional) in query: Platform ad account ID (e.g. act_123 for Meta). Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree. - **profileId** (optional) in query: Profile ID - **campaignId** (optional) in query: Platform campaign ID (filter ads within a campaign) - **platformAdId** (optional) in query: Meta ad ID. Returns the ad with this platform-side ad ID. - **effectiveObjectStoryId** (optional) in query: Facebook `{pageId}_{postId}` of the post the ad's engagement lives on (Meta `effective_object_story_id`). Use to map a Business-Manager-visible post back to the Zernio ad. - **effectiveInstagramMediaId** (optional) in query: Instagram media ID of the boosted post (Meta `effective_instagram_media_id`). Use to map a Business-Manager-visible IG post back to the Zernio ad. - **fromDate** (optional) in query: Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago. - **toDate** (optional) in query: End of metrics date range (YYYY-MM-DD). Defaults to today. Max 730-day range. ### Responses #### 200: Paginated ads **Response Body:** - **ads** `array[Ad]`: - **pagination**: `Pagination` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description --- # List campaigns associated with a conversion destination API Reference LinkedIn-only today. Returns the campaigns currently associated with this conversion rule. Note that auto-association on rule creation runs once at create time; campaigns created after the rule still need explicit association. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **List campaigns associated with a conversion destination** LinkedIn-only today. Returns the campaigns currently associated with this conversion rule. Note that auto-association on rule creation runs once at create time; campaigns created after the rule still need explicit association. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description ### Responses #### 200: Associations listed **Response Body:** - **platform** `string`: No description - one of: linkedinads - **associations** `array[object]`: - **campaignId** `string`: No description - **conversionId** `string`: No description - **associatedAt** `integer`: Epoch ms. #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## POST /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Associate campaigns with a conversion destination** Associate one or more campaigns with this conversion rule. Returns a per-campaign success/failure result so callers can retry only the rows that failed (e.g. wrong campaign type for the rule's objective). ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **campaignIds** (required) `array`: No description ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. Inputs that fail local URN validation are bucketed into `failed` without ever hitting LinkedIn. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully associated. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Remove campaign↔conversion associations** Remove one or more campaign associations from this conversion rule. Pass `adAccountId` and `campaignIds` as query parameters (`campaignIds` is comma-separated). The route also accepts a JSON body with the same fields for clients that prefer DELETE-with-body, but the documented surface is query-only because some SDK code generators (e.g. Python) collapse query + body parameters with the same name into a single kwarg. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description - **campaignIds** (required) in query: Comma-separated list of campaign IDs. ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully removed. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Validation error: missing `adAccountId` or `campaignIds`, or campaignIds exceeds 100 entries per request. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- --- # List destinations for the Conversions API API Reference Returns the list of pixels (Meta), conversion actions (Google), or conversion rules (LinkedIn) accessible to the connected ads account. Use the returned `id` as `destinationId` when posting to `POST /v1/ads/conversions`. For Google and LinkedIn, each destination's `type` reflects the conversion type (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is locked to the destination. For Meta, `type` is absent: pixels accept any event name per request. For LinkedIn, destinations are returned across every sponsored ad account the connected token can access; the `adAccountId` field on each destination identifies the parent ad account and is required for subsequent CRUD calls (update, delete, associations, metrics). ## GET /v1/accounts/{accountId}/conversion-destinations **List destinations for the Conversions API** Returns the list of pixels (Meta), conversion actions (Google), or conversion rules (LinkedIn) accessible to the connected ads account. Use the returned `id` as `destinationId` when posting to `POST /v1/ads/conversions`. For Google and LinkedIn, each destination's `type` reflects the conversion type (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is locked to the destination. For Meta, `type` is absent: pixels accept any event name per request. For LinkedIn, destinations are returned across every sponsored ad account the connected token can access; the `adAccountId` field on each destination identifies the parent ad account and is required for subsequent CRUD calls (update, delete, associations, metrics). ### Parameters - **accountId** (required) in path: SocialAccount ID (metaads, googleads, or linkedinads). ### Responses #### 200: Destinations listed **Response Body:** - **platform** `string`: No description - one of: metaads, googleads, linkedinads - **destinations** `array[object]`: - **id** `string`: Destination identifier. Meta: pixel ID. Google: conversion action resource name. LinkedIn: numeric conversion rule ID. - **name** `string`: No description - **type** `string`: Present when the platform locks event type to the destination (Google conversion actions, LinkedIn conversion rules). - **status** `string`: No description - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls. #### 400: Account's platform is not supported by the Conversions API. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected. #### 404: Account not found or not accessible. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## POST /v1/accounts/{accountId}/conversion-destinations **Create a conversion destination (LinkedIn, Google Ads)** Create a new conversion destination on the platform. Supported for LinkedIn (conversion rule) and Google Ads (conversion action). Meta manages destinations in its own UI and returns 405. **LinkedIn:** creation is NOT idempotent. A retry creates a second destination. Deduplicate before retrying. **Google Ads:** calling with a name that already exists reuses the existing conversion action transparently (the response is identical to a fresh create). Calling with the same name but a different category returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently returning the mismatched action. **LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API` and (by default) auto-associated with all of the ad account's campaigns via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE` to opt out and manage associations explicitly via the associations endpoints below. 365-day attribution windows are only valid for `SUBMIT_APPLICATION`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types; the API rejects other combinations locally. **Google Ads:** the conversion action is created with `type=UPLOAD_CLICKS` (required for API-uploaded offline conversions, immutable after creation). The `type` field carries the Google `ConversionActionCategory` enum value, e.g. `PURCHASE`, `SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`. Unified standard event names (e.g. `Purchase`, `Subscribe`, `CompleteRegistration`, `Lead`, `Schedule`) are resolved to their Google category equivalents automatically. The action defaults to secondary (non-primary) to avoid immediately steering Smart Bidding; pass `primaryForGoal: true` to opt in. ### Parameters - **accountId** (required) in path: SocialAccount ID (linkedinads or googleads). ### Request Body - **adAccountId** (required) `string`: Ad account ID. For LinkedIn: numeric (e.g. "5123456") or full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric customer ID (e.g. "1234567890") or `customers/{id}` form. - **name** (required) `string`: No description - **type** (required) `string`: Conversion type. For LinkedIn: a unified standard event name (e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google: a unified standard event name (Purchase, Subscribe, CompleteRegistration, Lead, Schedule) or a Google ConversionActionCategory enum value directly (e.g. "PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD", "BOOK_APPOINTMENT"). Unknown values pass through to the platform. - **attributionType** `string`: LinkedIn only. - one of: LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION - **postClickAttributionWindowSize** `integer`: LinkedIn only. Default 30. 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types; the API rejects other combinations locally. - one of: 1, 7, 30, 90, 365 - **viewThroughAttributionWindowSize** `integer`: LinkedIn only. Default 7. Same 365-day-window type restriction applies as `postClickAttributionWindowSize`. - one of: 1, 7, 30, 90, 365 - **valueType** `string`: LinkedIn only. DYNAMIC (default) uses the per-event `value` from `sendConversions`. FIXED uses the rule's `value` field. NO_VALUE drops monetary value entirely. - one of: DYNAMIC, FIXED, NO_VALUE - **value** `object`: LinkedIn only. Static conversion value. Used when `valueType=FIXED`. The currency should match the ad account's currency. - **autoAssociationType** `string`: LinkedIn only. Controls campaign association at rule-creation time: - ALL_CAMPAIGNS: associate the rule with every active, paused, and draft campaign in the ad account - OBJECTIVE_BASED: associate only campaigns whose objective matches the rule's type - NONE: don't auto-associate. Manage associations via the `/associations` endpoints below. Note: auto-association runs once at create time; new campaigns added after the rule still need explicit association. - one of: ALL_CAMPAIGNS, OBJECTIVE_BASED, NONE - **countingType** `string`: Google Ads only. Whether to count multiple conversions from the same click (MANY_PER_CLICK) or at most one (ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted. - one of: MANY_PER_CLICK, ONE_PER_CLICK - **primaryForGoal** `boolean`: Google Ads only. When true, the conversion action is marked as primary and immediately influences Smart Bidding. Defaults to false (secondary, record-only) to avoid unintentionally steering the customer's campaigns on creation. ### Responses #### 201: Destination created **Response Body:** - **platform** `string`: No description - one of: linkedinads, googleads - **destination**: `ConversionDestination` - See schema definition #### 400: Invalid body or platform validation failure. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required). #### 404: Account not found or not accessible. #### 405: Platform does not support destination creation. #### 409: Google Ads only. A conversion action with the given name already exists but has a different category. Use a different name or use the existing destination. Error code: `IDEMPOTENCY_CONFLICT`. #### 429: Rate limit hit. Retry with backoff. --- # Related Schema Definitions ## ConversionDestination A discoverable conversion destination on an ad platform — a Meta pixel, Google conversion action, or LinkedIn conversion rule. Returned by `listConversionDestinations`, `getConversionDestination`, `createConversionDestination`, and `updateConversionDestination`. ### Properties - **id** (required) `string`: Platform-native identifier. Pass back as `destinationId` on event send and as the path segment on CRUD endpoints. - **name** (required) `string`: No description - **type** `string`: Present when the platform locks the event type/category to the destination (Google conversion actions, LinkedIn conversion rules). Absent for Meta pixels (which accept any event name per request). - **status** `string`: For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`). - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls to identify the parent ad account. --- # List leads for a single form API Reference Returns leads for one form. Serves persisted leads (ingested via the leadgen webhook) when available, falling back to a live Graph read. ## GET /v1/ads/lead-forms/{formId}/leads **List leads for a single form** Returns leads for one form. Serves persisted leads (ingested via the leadgen webhook) when available, falling back to a live Graph read. ### Parameters - **formId** (required) in path: No description - **accountId** (required) in query: No description - **limit** (optional) in query: No description - **cursor** (optional) in query: No description - **since** (optional) in query: Unix seconds. ### Responses #### 200: Leads for the form. **Response Body:** - **status** `string`: No description (example: "success") - **leads** `array[object]`: - **id** `string`: No description - **createdTime** `string`: No description - **adId** `string`: No description - **formId** `string`: No description - **fields** `object`: No description - **fieldData** `array[object]`: Type: `object` - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # List Lead Gen (Instant) forms API Reference Lists the Lead Gen forms owned by the connected Facebook Page. Requires the Ads add-on. ## GET /v1/ads/lead-forms **List Lead Gen (Instant) forms** Lists the Lead Gen forms owned by the connected Facebook Page. Requires the Ads add-on. ### Parameters - **accountId** (required) in query: Connected facebook account id. - **limit** (optional) in query: No description - **cursor** (optional) in query: No description ### Responses #### 200: Forms list. **Response Body:** - **status** `string`: No description (example: "success") - **forms** `array[object]`: Type: `object` - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on required. --- ## POST /v1/ads/lead-forms **Create a Lead Gen (Instant) form** Creates a Lead Gen form on the connected Facebook Page (POST /{page-id}/leadgen_forms). NOT idempotent — a retry creates a second form. Prefilled question types (EMAIL, PHONE, FULL_NAME, …) must omit label/key; CUSTOM questions require both. Requires the Ads add-on. ### Request Body - **accountId** (required) `string`: No description - **name** (required) `string`: No description - **questions** (required) `array`: No description - **privacyPolicyUrl** (required) `string`: No description - **privacyPolicyLinkText** `string`: No description - **followUpActionUrl** `string`: No description - **locale** `string`: No description - **thankYouTitle** `string`: No description - **thankYouBody** `string`: No description - **thankYouButtonText** `string`: No description - **thankYouButtonType** `string`: No description - **thankYouWebsiteUrl** `string`: No description - **isOptimizedForQuality** `boolean`: No description ### Responses #### 200: Created form. **Response Body:** - **status** `string`: No description (example: "success") - **form** `object`: - **id** `string`: No description - **name** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on required. --- --- # List submitted leads (cross-form CRM view) API Reference Returns persisted Meta Lead Gen leads for your team, newest-first, with keyset pagination on `cursor`. Leads are ingested in real time from the `leadgen` webhook. Requires the Ads add-on. ## GET /v1/ads/leads **List submitted leads (cross-form CRM view)** Returns persisted Meta Lead Gen leads for your team, newest-first, with keyset pagination on `cursor`. Leads are ingested in real time from the `leadgen` webhook. Requires the Ads add-on. ### Parameters - **formId** (optional) in query: Filter to a single lead form. - **accountId** (optional) in query: Filter to a single connected account. - **limit** (optional) in query: No description - **since** (optional) in query: Unix seconds; only leads created at/after this Meta timestamp. - **cursor** (optional) in query: Keyset cursor from a previous response's pagination.cursor. ### Responses #### 200: Lead list. **Response Body:** - **status** `string`: No description (example: "success") - **leads** `array[object]`: - **id** `string`: Zernio lead id. - **leadgenId** `string`: Meta lead id. - **formId** `string`: No description - **formName** `string`: No description - **accountId** `string`: No description - **adId** `string`: No description - **adsetId** `string`: No description - **campaignId** `string`: No description - **isOrganic** `boolean`: No description - **createdTime** `string`: ISO 8601. - **fields** `object`: Question key → answer. - **fieldData** `array[object]`: Raw Meta field_data. Type: `object` - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on required. --- --- # List recent WhatsApp conversion events API Reference Returns the most recent conversion events sent through `POST /v1/whatsapp/conversions` for the given WhatsApp account. Sourced from delivery logs (Axiom `late` dataset), so the visible window is bounded by log retention (about 30 days). Useful for rendering a "recent activity" panel on the conversions setup tab without standing up a parallel persistence layer. Per-event payload mirrors the structured log we write on every successful send: `eventName`, `conversationId`, `eventsReceived`, `eventsFailed`, `traceId`, `durationMs`, and the wall-clock `timestamp`. ## GET /v1/whatsapp/conversions **List recent WhatsApp conversion events** Returns the most recent conversion events sent through `POST /v1/whatsapp/conversions` for the given WhatsApp account. Sourced from delivery logs (Axiom `late` dataset), so the visible window is bounded by log retention (about 30 days). Useful for rendering a "recent activity" panel on the conversions setup tab without standing up a parallel persistence layer. Per-event payload mirrors the structured log we write on every successful send: `eventName`, `conversationId`, `eventsReceived`, `eventsFailed`, `traceId`, `durationMs`, and the wall-clock `timestamp`. ### Parameters - **accountId** (required) in query: WhatsApp social account ID - **limit** (optional) in query: Max events to return (1-200, default 50). ### Responses #### 200: Recent conversion events **Response Body:** - **events** `array[object]`: - **timestamp** `string` (date-time): When the event was sent to Meta. - **eventName** `string`: One of LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent. - **conversationId** `string`: No description - **eventsReceived** `integer`: Number of events Meta accepted on this send (usually 1). - **eventsFailed** `integer`: Number of events Meta rejected (usually 0). - **traceId** `string`: Meta fbtrace_id for cross-referencing in Events Manager. - **durationMs** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: WhatsApp account not found --- ## POST /v1/whatsapp/conversions **Send WhatsApp conversion event** Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`, `Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's Conversions API with `action_source = business_messaging` and `messaging_channel = whatsapp`. The endpoint looks up the originating CTWA click ID (`ctwa_clid`) captured on the first inbound message of the conversation and replays it on every event so Meta can attribute the conversion back to the Click-to-WhatsApp ad that drove the chat. Configuration prerequisite on the WhatsApp account metadata: - `metaCapiDatasetId`: the Meta dataset ID linked to the WABA. Provision one with `POST /v1/whatsapp/dataset`. The WABA ID (already set automatically at connect time) is forwarded as `user_data.whatsapp_business_account_id`, which is the per-channel attribution identifier Meta requires for WhatsApp events. No Facebook Page ID is needed (that field is the Messenger-branch identifier). Identify the conversation by either `conversationId` (preferred) or `phoneE164` (digits only, no `+`). At least one is required. If the conversation has no captured `ctwa_clid`, the request returns 422 because there is nothing to attribute. Token and dataset coupling: the WhatsApp account's accessToken must have access to the configured `metaCapiDatasetId`. By default a WABA's system-user token is scoped to the WABA's own Business Manager and cannot post to a pixel owned by a different Business; Meta returns code 100 in that case. Either share the dataset with the WhatsApp app's Business in BM, or use a dataset already in the same Business as the WABA. ### Request Body - **accountId** (required) `string`: WhatsApp SocialAccount ID. - **eventName** (required) `string`: 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. - one of: LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent - **eventTime** `number`: 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** (required) `string`: Stable dedup key. Reuse to suppress duplicate events (Meta dedupes against pixel events with the same id). - **conversationId** `string`: 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** `string`: 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** `number`: Conversion value (e.g. order total). - **currency** `string`: ISO 4217 currency code (e.g. `USD`). - **contentIds** `array`: Optional product / content identifiers. - **email** `string`: User email. Normalized + SHA-256 hashed before sending to Meta. - **externalId** `string`: Stable customer identifier. Lowercased + SHA-256 hashed before sending to Meta. - **testCode** `string`: 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: 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. **Response Body:** - **platform** `string`: No description - one of: metaads - **eventsReceived** `integer`: Events accepted by Meta. - **eventsFailed** `integer`: Events rejected by Meta (see failures). - **failures** `array[object]`: Per-event failure detail. Empty when all events were accepted. - **eventIndex** `integer`: Index into the submitted events array. - **eventId** `string`: Echoes back the eventId of the failed event. - **message** `string`: No description - **code**: One of multiple types - **traceId** `string`: Meta `fbtrace_id` for debugging. Surface in support tickets. #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Conversation not found. #### 422: Configuration missing (no `metaCapiDatasetId` on the account, set it via POST /v1/whatsapp/dataset) OR the resolved conversation has no captured `ctwa_clid`. --- --- # Remove campaign↔conversion associations API Reference Remove one or more campaign associations from this conversion rule. Pass `adAccountId` and `campaignIds` as query parameters (`campaignIds` is comma-separated). The route also accepts a JSON body with the same fields for clients that prefer DELETE-with-body, but the documented surface is query-only because some SDK code generators (e.g. Python) collapse query + body parameters with the same name into a single kwarg. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **List campaigns associated with a conversion destination** LinkedIn-only today. Returns the campaigns currently associated with this conversion rule. Note that auto-association on rule creation runs once at create time; campaigns created after the rule still need explicit association. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description ### Responses #### 200: Associations listed **Response Body:** - **platform** `string`: No description - one of: linkedinads - **associations** `array[object]`: - **campaignId** `string`: No description - **conversionId** `string`: No description - **associatedAt** `integer`: Epoch ms. #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## POST /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Associate campaigns with a conversion destination** Associate one or more campaigns with this conversion rule. Returns a per-campaign success/failure result so callers can retry only the rows that failed (e.g. wrong campaign type for the rule's objective). ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **campaignIds** (required) `array`: No description ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. Inputs that fail local URN validation are bucketed into `failed` without ever hitting LinkedIn. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully associated. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations **Remove campaign↔conversion associations** Remove one or more campaign associations from this conversion rule. Pass `adAccountId` and `campaignIds` as query parameters (`campaignIds` is comma-separated). The route also accepts a JSON body with the same fields for clients that prefer DELETE-with-body, but the documented surface is query-only because some SDK code generators (e.g. Python) collapse query + body parameters with the same name into a single kwarg. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: No description - **campaignIds** (required) in query: Comma-separated list of campaign IDs. ### Responses #### 200: Per-campaign batch result. Status is 200 even when some rows failed — inspect `failed[]` for details. **Response Body:** - **platform** `string`: No description - one of: linkedinads - **succeeded** `array[string]`: Numeric campaign IDs that were successfully removed. - **failed** `array[object]`: - **campaignId** `string`: No description - **reason** `string`: No description #### 400: Validation error: missing `adAccountId` or `campaignIds`, or campaignIds exceeds 100 entries per request. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support associations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- --- # Search targeting interests (deprecated) API Reference Deprecated alias for `GET /v1/ads/targeting/search?dimension=interest`. Kept for backward compatibility, it returns the legacy `{ interests: [...] }` shape rather than the normalized `{ results: [...] }`. New integrations should use `GET /v1/ads/targeting/search` with `dimension=interest`. ## GET /v1/ads/interests **Search targeting interests (deprecated)** Deprecated alias for `GET /v1/ads/targeting/search?dimension=interest`. Kept for backward compatibility, it returns the legacy `{ interests: [...] }` shape rather than the normalized `{ results: [...] }`. New integrations should use `GET /v1/ads/targeting/search` with `dimension=interest`. ### Parameters - **q** (required) in query: Search query - **accountId** (required) in query: Social account ID ### Responses #### 200: Matching interests **Response Body:** - **interests** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **category** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. --- --- # Search targeting options API Reference Resolve a human-readable query into the platform's opaque targeting ids used in the `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and `interests`/`behaviors` entity ids) on `POST /v1/ads/create`, `POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. The `dimension` param selects what is searched, `geo` (locations, further scoped by `geoType`), `interest`, `behavior`, or `income`. Availability of each dimension varies by platform (e.g. behaviours are Meta/TikTok only). Results are normalized across platforms into a single shape, so the same client code consumes Meta, TikTok, LinkedIn, X, Pinterest, and Google results. For geo queries, `q` should contain only the locality name (e.g. `"Amsterdam"`, not `"Amsterdam, NL"`). Use `countryCode` to disambiguate. ## GET /v1/ads/targeting/search **Search targeting options** Resolve a human-readable query into the platform's opaque targeting ids used in the `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and `interests`/`behaviors` entity ids) on `POST /v1/ads/create`, `POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. The `dimension` param selects what is searched, `geo` (locations, further scoped by `geoType`), `interest`, `behavior`, or `income`. Availability of each dimension varies by platform (e.g. behaviours are Meta/TikTok only). Results are normalized across platforms into a single shape, so the same client code consumes Meta, TikTok, LinkedIn, X, Pinterest, and Google results. For geo queries, `q` should contain only the locality name (e.g. `"Amsterdam"`, not `"Amsterdam, NL"`). Use `countryCode` to disambiguate. ### Parameters - **accountId** (required) in query: Social account ID (a connected account on the target ad platform). - **q** (required) in query: Search query. For geo, the locality name only (no region/country suffix). - **dimension** (optional) in query: What to search. `geo` resolves locations (scope further with `geoType`), `interest`/`behavior` resolve audience entities, `income` resolves income-tier options. Defaults to `interest` for backward compatibility with the deprecated /v1/ads/interests alias. - **geoType** (optional) in query: Only used when `dimension=geo`. The kind of location to resolve. Defaults to `city`. - **countryCode** (optional) in query: ISO 3166-1 alpha-2 country code (e.g. NL) to scope a geo search. - **limit** (optional) in query: Maximum results to return. ### Responses #### 200: Matching targeting options (normalized) **Response Body:** - **results** `array[object]`: - **id** (required) `string`: The platform's opaque id. Use as a geo `key` (regions/cities/zips/metros) or an entity `id` (interests/behaviors) in TargetingSpec. - **name** (required) `string`: Human-readable label. - **type** (required) `string`: What the result is (e.g. city, region, country, zip, metro, interest, behavior, income). - **path** `array[string]`: Optional breadcrumb of parent labels (e.g. ['United States', 'California', 'Los Angeles']). Disambiguates same-named results. - **audienceSize** `integer`: Optional estimated reachable users for this option, when the platform returns it. #### 400: Missing or invalid query parameters #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. #### 404: Account not found, or the platform does not support the requested dimension --- --- # Send conversion events to an ad platform API Reference Relay one or more conversion events to the target ad platform's native Conversions API. Platform is inferred from the provided `accountId`. Requires the Ads add-on. Supported platforms: - Meta (`metaads`) via Graph API - Google Ads (`googleads`) via Data Manager API `ingestEvents` - LinkedIn (`linkedinads`) via `/rest/conversionEvents` `destinationId` semantics differ per platform: - Meta: pixel (dataset) ID, e.g. `123456789012345` - Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321` - LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012` Callers can list valid destinations via `GET /v1/accounts/{accountId}/conversion-destinations`. All PII (email, phone, names, external IDs) is hashed with SHA-256 server-side per each platform's normalization spec, including Google's Gmail-specific dot/plus-suffix stripping. Send plaintext. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec; only emails and phones are hashed. For LinkedIn, the connected account must have been authorized after the Conversions API rollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect. Batching is handled automatically. Meta caps at 1000 events per request and rejects the entire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is also all-or-nothing per chunk. Dedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against browser-side pixel/Insight Tag events; Google maps it to `transactionId`. Per-platform `eventName` semantics: - Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted. - Google: ignored. The conversion action's category determines the event type. Send the standard name closest to your action for documentation, but the platform will not branch on it. - LinkedIn: ignored. The conversion rule's `type` (LEAD, PURCHASE, etc.) is locked to the destination at rule-creation time. Send the standard name for documentation; LinkedIn does not branch on it. ## POST /v1/ads/conversions **Send conversion events to an ad platform** Relay one or more conversion events to the target ad platform's native Conversions API. Platform is inferred from the provided `accountId`. Requires the Ads add-on. Supported platforms: - Meta (`metaads`) via Graph API - Google Ads (`googleads`) via Data Manager API `ingestEvents` - LinkedIn (`linkedinads`) via `/rest/conversionEvents` `destinationId` semantics differ per platform: - Meta: pixel (dataset) ID, e.g. `123456789012345` - Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321` - LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012` Callers can list valid destinations via `GET /v1/accounts/{accountId}/conversion-destinations`. All PII (email, phone, names, external IDs) is hashed with SHA-256 server-side per each platform's normalization spec, including Google's Gmail-specific dot/plus-suffix stripping. Send plaintext. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec; only emails and phones are hashed. For LinkedIn, the connected account must have been authorized after the Conversions API rollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect. Batching is handled automatically. Meta caps at 1000 events per request and rejects the entire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is also all-or-nothing per chunk. Dedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against browser-side pixel/Insight Tag events; Google maps it to `transactionId`. Per-platform `eventName` semantics: - Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted. - Google: ignored. The conversion action's category determines the event type. Send the standard name closest to your action for documentation, but the platform will not branch on it. - LinkedIn: ignored. The conversion rule's `type` (LEAD, PURCHASE, etc.) is locked to the destination at rule-creation time. Send the standard name for documentation; LinkedIn does not branch on it. ### Request Body - **accountId** (required) `string`: SocialAccount ID (metaads, googleads, or linkedinads). - **destinationId** (required) `string`: Platform destination identifier. For Meta, the pixel/dataset ID. For Google, the conversion action resource name. For LinkedIn, the conversion rule ID or full `urn:lla:llaPartnerConversion:{id}` URN. - **events** (required) `array`: No description - **testCode** `string`: Meta `test_event_code` passthrough. Ignored by Google and LinkedIn. - **consent** `object`: Batch-level user consent. Required by Google for EEA/UK events under the Feb 2026 restrictions. Ignored by Meta and LinkedIn. ### Responses #### 200: 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. **Response Body:** - **platform** `string`: No description - one of: metaads, googleads, linkedinads - **eventsReceived** `integer`: Events accepted by the platform. - **eventsFailed** `integer`: Events rejected (see failures). - **failures** `array[object]`: - **eventIndex** `integer`: Index into the submitted events array. - **eventId** `string`: Echoes back the eventId of the failed event. - **message** `string`: No description - **code**: One of multiple types - **traceId** `string`: Platform trace ID for debugging. fbtrace_id for Meta, requestId for Google. Absent for LinkedIn (LinkedIn's conversionEvents endpoint does not surface a trace ID). #### 400: Invalid body (missing accountId/destinationId/events, malformed event shape). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected. #### 404: Account not found or not accessible. #### 429: LinkedIn token-level rate limit hit (600 requests/min, 300k/day per token). Retry with backoff. Meta and Google have their own rate-limit semantics surfaced via platform-specific 4xx responses. --- --- # Send WhatsApp conversion event API Reference Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`, `Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's Conversions API with `action_source = business_messaging` and `messaging_channel = whatsapp`. The endpoint looks up the originating CTWA click ID (`ctwa_clid`) captured on the first inbound message of the conversation and replays it on every event so Meta can attribute the conversion back to the Click-to-WhatsApp ad that drove the chat. Configuration prerequisite on the WhatsApp account metadata: - `metaCapiDatasetId`: the Meta dataset ID linked to the WABA. Provision one with `POST /v1/whatsapp/dataset`. The WABA ID (already set automatically at connect time) is forwarded as `user_data.whatsapp_business_account_id`, which is the per-channel attribution identifier Meta requires for WhatsApp events. No Facebook Page ID is needed (that field is the Messenger-branch identifier). Identify the conversation by either `conversationId` (preferred) or `phoneE164` (digits only, no `+`). At least one is required. If the conversation has no captured `ctwa_clid`, the request returns 422 because there is nothing to attribute. Token and dataset coupling: the WhatsApp account's accessToken must have access to the configured `metaCapiDatasetId`. By default a WABA's system-user token is scoped to the WABA's own Business Manager and cannot post to a pixel owned by a different Business; Meta returns code 100 in that case. Either share the dataset with the WhatsApp app's Business in BM, or use a dataset already in the same Business as the WABA. ## GET /v1/whatsapp/conversions **List recent WhatsApp conversion events** Returns the most recent conversion events sent through `POST /v1/whatsapp/conversions` for the given WhatsApp account. Sourced from delivery logs (Axiom `late` dataset), so the visible window is bounded by log retention (about 30 days). Useful for rendering a "recent activity" panel on the conversions setup tab without standing up a parallel persistence layer. Per-event payload mirrors the structured log we write on every successful send: `eventName`, `conversationId`, `eventsReceived`, `eventsFailed`, `traceId`, `durationMs`, and the wall-clock `timestamp`. ### Parameters - **accountId** (required) in query: WhatsApp social account ID - **limit** (optional) in query: Max events to return (1-200, default 50). ### Responses #### 200: Recent conversion events **Response Body:** - **events** `array[object]`: - **timestamp** `string` (date-time): When the event was sent to Meta. - **eventName** `string`: One of LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent. - **conversationId** `string`: No description - **eventsReceived** `integer`: Number of events Meta accepted on this send (usually 1). - **eventsFailed** `integer`: Number of events Meta rejected (usually 0). - **traceId** `string`: Meta fbtrace_id for cross-referencing in Events Manager. - **durationMs** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: WhatsApp account not found --- ## POST /v1/whatsapp/conversions **Send WhatsApp conversion event** Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`, `Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's Conversions API with `action_source = business_messaging` and `messaging_channel = whatsapp`. The endpoint looks up the originating CTWA click ID (`ctwa_clid`) captured on the first inbound message of the conversation and replays it on every event so Meta can attribute the conversion back to the Click-to-WhatsApp ad that drove the chat. Configuration prerequisite on the WhatsApp account metadata: - `metaCapiDatasetId`: the Meta dataset ID linked to the WABA. Provision one with `POST /v1/whatsapp/dataset`. The WABA ID (already set automatically at connect time) is forwarded as `user_data.whatsapp_business_account_id`, which is the per-channel attribution identifier Meta requires for WhatsApp events. No Facebook Page ID is needed (that field is the Messenger-branch identifier). Identify the conversation by either `conversationId` (preferred) or `phoneE164` (digits only, no `+`). At least one is required. If the conversation has no captured `ctwa_clid`, the request returns 422 because there is nothing to attribute. Token and dataset coupling: the WhatsApp account's accessToken must have access to the configured `metaCapiDatasetId`. By default a WABA's system-user token is scoped to the WABA's own Business Manager and cannot post to a pixel owned by a different Business; Meta returns code 100 in that case. Either share the dataset with the WhatsApp app's Business in BM, or use a dataset already in the same Business as the WABA. ### Request Body - **accountId** (required) `string`: WhatsApp SocialAccount ID. - **eventName** (required) `string`: 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. - one of: LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent - **eventTime** `number`: 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** (required) `string`: Stable dedup key. Reuse to suppress duplicate events (Meta dedupes against pixel events with the same id). - **conversationId** `string`: 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** `string`: 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** `number`: Conversion value (e.g. order total). - **currency** `string`: ISO 4217 currency code (e.g. `USD`). - **contentIds** `array`: Optional product / content identifiers. - **email** `string`: User email. Normalized + SHA-256 hashed before sending to Meta. - **externalId** `string`: Stable customer identifier. Lowercased + SHA-256 hashed before sending to Meta. - **testCode** `string`: 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: 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. **Response Body:** - **platform** `string`: No description - one of: metaads - **eventsReceived** `integer`: Events accepted by Meta. - **eventsFailed** `integer`: Events rejected by Meta (see failures). - **failures** `array[object]`: Per-event failure detail. Empty when all events were accepted. - **eventIndex** `integer`: Index into the submitted events array. - **eventId** `string`: Echoes back the eventId of the failed event. - **message** `string`: No description - **code**: One of multiple types - **traceId** `string`: Meta `fbtrace_id` for debugging. Surface in support tickets. #### 400: Invalid body. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Conversation not found. #### 422: Configuration missing (no `metaCapiDatasetId` on the account, set it via POST /v1/whatsapp/dataset) OR the resolved conversation has no captured `ctwa_clid`. --- --- # Set/update an ad's click-URL tracking tags API Reference Unified update. Send only the fields for the ad's platform: - Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim (re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags` ALONE — no need to read back headline/body/CTA. `creative` (headline, body, callToAction, linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post / dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for `creative`). - Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies). - LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM). ## GET /v1/ads/{adId}/tracking-tags **Read an ad's click-URL tracking tags** Unified read of the platform's native click-URL tracking params. - Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec). - Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`. Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access. - LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`. Returns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest). ### Parameters - **adId** (required) in path: Ad id (hex _id, platformAdId, or effective story/media id). ### Responses #### 200: Tracking tags for the ad's platform (shape varies by platform). **Response Body:** - **platform** `string`: No description - **level** `string`: No description - one of: creative, campaign - **urlTags** `string`: Meta: &-joined click-URL params. - **templateUrlSpec** `object`: Meta: third-party click-tracking template (Dynamic Ads). - **trackingUrlTemplate** `string`: Google. - **finalUrlSuffix** `string`: Google. - **dynamicValueParameters** `object`: LinkedIn. - **customValueParameters** `object`: LinkedIn. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad not found #### 405: Platform has no click-URL tracking surface --- ## PATCH /v1/ads/{adId}/tracking-tags **Set/update an ad's click-URL tracking tags** Unified update. Send only the fields for the ad's platform: - Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim (re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags` ALONE — no need to read back headline/body/CTA. `creative` (headline, body, callToAction, linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post / dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for `creative`). - Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies). - LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM). ### Parameters - **adId** (required) in path: No description ### Request Body - **urlTags** `array`: Meta only. Click-URL params appended to a freshly-rebuilt creative. - **creative** `object`: Meta only. OPTIONAL — omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips. - **trackingUrlTemplate** `string`: Google only. Full tracking template (must contain {lpurl}). - **finalUrlSuffix** `string`: Google only. Parse-only key=value params. - **dynamicValueParameters** `object`: LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...). - **customValueParameters** `object`: LinkedIn only. key -> static value. ### Responses #### 200: Updated #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Ad not found #### 405: Platform has no click-URL tracking surface #### 422: Meta creative cannot be rebuilt (e.g. placement-customized/asset-feed/dark creative) --- --- # Update ad API Reference 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`. ## GET /v1/ads/{adId} **Get ad details** Returns an ad with its creative, targeting, status, and performance metrics. The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad: - the Zernio internal `_id` (24-char hex) - Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`) - the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side) - the creative's `effective_instagram_media_id` (Instagram side) Any of the four resolve to the same ad. Caller doesn't need a translation step. ### Parameters - **adId** (required) in path: Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details. ### Responses #### 200: Ad details **Response Body:** - **ad**: `Ad` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/ads/{adId} **Update ad** 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`. ### Parameters - **adId** (required) in path: No description ### Request Body - **status** `string`: No description - one of: active, paused - **budget** `object`: No description - **targeting** `object`: Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501. - **creative** `object`: 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. - **name** `string`: Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501. ### Responses #### 200: Ad updated **Response Body:** - **ad**: `Ad` - See schema definition - **message** `string`: No description #### 400: Invalid status transition or budget below minimum #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 501: targeting or creative not supported on the platform (Meta + TikTok only) --- ## DELETE /v1/ads/{adId} **Cancel an ad** Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history. ### Parameters - **adId** (required) in path: No description ### Responses #### 200: Ad cancelled **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Ad ### Properties - **_id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter - **status**: No description - **adType** `string`: No description - one of: boost, standalone - **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales - **isExternal** `boolean`: True for ads synced from platform ad managers - **budget** `object`: - **amount** `number`: - **type** `string`: - one of: daily, lifetime - **metrics**: No description - **platformAdId** `string`: No description - **platformAdAccountId** `string`: No description - **platformCampaignId** `string`: No description - **platformAdSetId** `string`: No description - **campaignName** `string`: No description - **adSetName** `string`: No description - **platformObjective** `string`: Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads. - **optimizationGoal** `string`: Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads. - **platformAdAccountName** `string`: 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. - **platformCreatedAt** `string`: 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**: 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`. - **bidAmount** `number`: 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`). - **roasAverageFloor** `number`: 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. - **promotedObject** `object`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads. - **custom_event_type** `string`: Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART) - **pixel_id** `string`: Meta pixel ID - **page_id** `string`: Facebook page ID - **application_id** `string`: Facebook app ID - **product_set_id** `string`: Product catalog set ID - **creative** `object`: Platform-specific creative data. Fields vary by platform. - **thumbnailUrl** `string`: Primary thumbnail/image URL - **imageUrl** `string`: Alternative image URL - **videoId** `string`: 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** `string`: Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads. - **objectType** `string`: Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available. - **objectStoryId** `string`: Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead. - **effectiveObjectStoryId** `string`: Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it. - **effectiveInstagramMediaId** `string`: Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad. - **instagramUserId** `string`: Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media. - **instagramPermalinkUrl** `string`: Meta `instagram_permalink_url` — public Instagram post URL of the boosted media. - **mediaUrls** `array`: 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** `string`: Ad copy/text - **googleHeadline** `string`: Google Ads headline - **googleDescription** `string`: Google Ads description - **linkUrl** `string`: Destination URL - **pinterestImageUrl** `string`: - **pinterestTitle** `string`: - **pinterestDescription** `string`: - **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions). For ads created through Zernio this is the spec you supplied. For external ads (synced from Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest, so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad; the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`), the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails. - **schedule** `object`: - **startDate** `string`: - **endDate** `string`: - **rejectionReason** `string`: No description - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Update a conversion destination API Reference Partial-update a conversion rule. LinkedIn-only today. Whitelisted fields: `name`, `enabled`, attribution windows, `valueType`, `value`, `attributionType`. The rule's `type` and parent ad account are intentionally not exposed for update — recreate the rule if those need to change. ## GET /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Fetch a single conversion destination** LinkedIn-only today. Returns the full destination record for one conversion rule. The `adAccountId` query parameter is required because LinkedIn rules are scoped to a sponsored ad account. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (required) in query: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN. ### Responses #### 200: Destination fetched **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support fetching a single destination. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## PATCH /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Update a conversion destination** Partial-update a conversion rule. LinkedIn-only today. Whitelisted fields: `name`, `enabled`, attribution windows, `valueType`, `value`, `attributionType`. The rule's `type` and parent ad account are intentionally not exposed for update — recreate the rule if those need to change. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description ### Request Body - **adAccountId** (required) `string`: No description - **name** `string`: No description - **enabled** `boolean`: Setting `false` is equivalent to calling DELETE — the rule will appear as `inactive` afterwards. - **attributionType** `string`: No description - one of: LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION - **postClickAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **viewThroughAttributionWindowSize** `integer`: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - one of: 1, 7, 30, 90, 365 - **valueType** `string`: No description - one of: DYNAMIC, FIXED, NO_VALUE - **value** `object`: Used when `valueType=FIXED`. ### Responses #### 200: Destination updated (re-fetched canonical state) **Response Body:** - **platform** `string`: No description - one of: linkedinads - **destination**: `ConversionDestination` - See schema definition #### 400: Invalid body or LinkedIn validation failure. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support updating destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- ## DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId} **Soft-delete a conversion destination** LinkedIn-only today. LinkedIn does not expose hard-delete on conversion rules — what their UI calls "delete" is the same `enabled: false` flip we apply here. The rule remains fetchable via GET with `status: 'inactive'`; the unified discovery endpoint hides it by default. `adAccountId` may be passed as a query parameter (recommended) or as a JSON body field for clients that can send DELETE bodies. ### Parameters - **accountId** (required) in path: No description - **destinationId** (required) in path: No description - **adAccountId** (optional) in query: Required as query OR in JSON body. ### Responses #### 204: Soft-deleted. #### 400: adAccountId missing. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads add-on or LinkedIn reconnect required. #### 404: Account or destination not found. #### 405: Platform does not support deleting destinations. #### 429: LinkedIn rate limit hit. Retry with backoff. --- # Related Schema Definitions ## ConversionDestination A discoverable conversion destination on an ad platform — a Meta pixel, Google conversion action, or LinkedIn conversion rule. Returned by `listConversionDestinations`, `getConversionDestination`, `createConversionDestination`, and `updateConversionDestination`. ### Properties - **id** (required) `string`: Platform-native identifier. Pass back as `destinationId` on event send and as the path segment on CRUD endpoints. - **name** (required) `string`: No description - **type** `string`: Present when the platform locks the event type/category to the destination (Google conversion actions, LinkedIn conversion rules). Absent for Meta pixels (which accept any event name per request). - **status** `string`: For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`). - one of: active, inactive - **adAccountId** `string`: Set by adapters whose destinations are scoped to a specific ad account (LinkedIn). Pass back on subsequent CRUD calls to identify the parent ad account. --- # Get post analytics API Reference 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. ## GET /v1/analytics **Get post analytics** 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 - **postId** (optional) in query: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics. - **platform** (optional) in query: Filter by platform (default "all") - **profileId** (optional) in query: Filter by profile ID (default "all") - **accountId** (optional) in query: Filter by social account ID - **source** (optional) in query: Filter by post source: late (posted via Zernio API), external (synced from platform), all (default) - **fromDate** (optional) in query: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days. - **toDate** (optional) in query: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted. - **limit** (optional) in query: Page size (default 50) - **page** (optional) in query: Page number (default 1) - **sortBy** (optional) in query: Sort by date, engagement, or a specific metric - **order** (optional) in query: Sort order ### Responses #### 200: Analytics result **Response Body:** *One of the following:* - `AnalyticsSinglePostResponse` - `AnalyticsListResponse` #### 202: Analytics are being synced from the platform (single post lookup only). The response body matches AnalyticsSinglePostResponse with syncStatus "pending" and a message. **Response Body:** - **postId** `string`: No description - **latePostId** `string`: Original Zernio post ID if scheduled via Zernio - **status** `string`: Overall post status. "partial" when some platforms published and others failed. - one of: published, failed, partial - **content** `string`: No description - **scheduledFor** `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **analytics**: `PostAnalytics` - See schema definition - **platformAnalytics** `array[PlatformAnalytics]`: - **platform** `string`: No description - **platformPostUrl** `string` (uri): No description - **isExternal** `boolean`: No description - **syncStatus** `string`: Overall sync state across all platforms - one of: synced, pending, partial, unavailable - **message** `string`: Human-readable status message for pending, partial, or failed states - **thumbnailUrl** `string` (uri): No description - **mediaType** `string`: No description - one of: image, video, carousel, text - **mediaItems** `array[object]`: All media items for this post. Carousel posts contain one entry per slide. - **type** `string`: No description - one of: image, video - **url** `string` (uri): Direct URL to the media - **thumbnail** `string` (uri): Thumbnail URL (same as url for images) #### 400: Validation error **Response Body:** - **error** `string`: No description (example: "Invalid query parameters") - **details** `object`: Detailed validation errors #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 424: Post failed to publish on all platforms. Analytics are unavailable. (single post lookup only) **Response Body:** - **postId** `string`: No description - **latePostId** `string`: Original Zernio post ID if scheduled via Zernio - **status** `string`: Overall post status. "partial" when some platforms published and others failed. - one of: published, failed, partial - **content** `string`: No description - **scheduledFor** `string` (date-time): No description - **publishedAt** `string` (date-time): No description - **analytics**: `PostAnalytics` - See schema definition - **platformAnalytics** `array[PlatformAnalytics]`: - **platform** `string`: No description - **platformPostUrl** `string` (uri): No description - **isExternal** `boolean`: No description - **syncStatus** `string`: Overall sync state across all platforms - one of: synced, pending, partial, unavailable - **message** `string`: Human-readable status message for pending, partial, or failed states - **thumbnailUrl** `string` (uri): No description - **mediaType** `string`: No description - one of: image, video, carousel, text - **mediaItems** `array[object]`: All media items for this post. Carousel posts contain one entry per slide. - **type** `string`: No description - one of: image, video - **url** `string` (uri): Direct URL to the media - **thumbnail** `string` (uri): Thumbnail URL (same as url for images) #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## AnalyticsSinglePostResponse ### Properties - **postId** `string`: No description - **latePostId** `string`: Original Zernio post ID if scheduled via Zernio - **status** `string`: Overall post status. "partial" when some platforms published and others failed. - one of: published, failed, partial - **content** `string`: No description - **scheduledFor** `string`: No description - **publishedAt** `string`: No description - **analytics**: No description - **platformAnalytics** `array`: No description - **platform** `string`: No description - **platformPostUrl** `string`: No description - **isExternal** `boolean`: No description - **syncStatus** `string`: Overall sync state across all platforms - one of: synced, pending, partial, unavailable - **message** `string`: Human-readable status message for pending, partial, or failed states - **thumbnailUrl** `string`: No description - **mediaType** `string`: No description - one of: image, video, carousel, text - **mediaItems** `array`: All media items for this post. Carousel posts contain one entry per slide. ## AnalyticsListResponse ### Properties - **overview**: No description - **posts** `array`: No description - **pagination**: No description - **accounts** `array`: Connected social accounts (followerCount and followersLastUpdated only included if user has analytics add-on) - **hasAnalyticsAccess** `boolean`: Whether user has analytics add-on access ## PostAnalytics ### Properties - **impressions** `integer`: No description - **reach** `integer`: No description - **likes** `integer`: No description - **comments** `integer`: No description - **shares** `integer`: No description - **saves** `integer`: Number of saves/bookmarks (Instagram, Pinterest) - **clicks** `integer`: No description - **views** `integer`: No description - **igReelsAvgWatchTime** `integer`: Instagram Reels only: average watch time per play, in milliseconds. 0 for non-Reels media and other platforms. - **igReelsVideoViewTotalTime** `integer`: Instagram Reels only: total watch time including replays, in milliseconds. 0 for non-Reels media and other platforms. - **engagementRate** `number`: No description - **lastUpdated** `string`: No description ## PlatformAnalytics ### Properties - **platform** `string`: No description - **status** `string`: No description - one of: published, failed - **platformPostId** `string`: The native post ID on the platform (e.g. Instagram media ID, tweet ID) - **accountId** `string`: No description - **accountUsername** `string`: No description - **analytics**: No description - **syncStatus** `string`: Sync state of analytics for this platform - one of: synced, pending, unavailable - **platformPostUrl** `string`: No description - **errorMessage** `string`: Error details when status is failed ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get best times to post API Reference 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. ## GET /v1/analytics/best-time **Get best times to post** 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 - **platform** (optional) in query: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - **profileId** (optional) in query: Filter by profile ID. Omit for all profiles. - **accountId** (optional) in query: Filter by social account ID. Omit for all accounts. - **source** (optional) in query: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. ### Responses #### 200: Best time slots **Response Body:** - **slots** `array[object]`: - **day_of_week** `integer`: 0=Monday, 6=Sunday - **hour** `integer`: Hour in UTC (0-23) - **avg_engagement** `number`: Average engagement (likes + comments + shares + saves) - **post_count** `integer`: Number of posts in this slot #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **requiresAddon** `boolean`: No description (example: true) --- --- # Get content performance decay API Reference 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. ## GET /v1/analytics/content-decay **Get content performance decay** 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 - **platform** (optional) in query: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - **profileId** (optional) in query: Filter by profile ID. Omit for all profiles. - **accountId** (optional) in query: Filter by social account ID. Omit for all accounts. - **source** (optional) in query: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. ### Responses #### 200: Content decay buckets **Response Body:** - **buckets** `array[object]`: - **bucket_order** `integer`: Sort order (0 = earliest, 6 = latest) - **bucket_label** `string`: Human-readable label - **avg_pct_of_final** `number`: Average % of final engagement reached (0-100) - **post_count** `integer`: Number of posts with data in this bucket #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **requiresAddon** `boolean`: No description (example: true) --- --- # Get daily aggregated metrics API Reference 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. ## GET /v1/analytics/daily-metrics **Get daily aggregated metrics** 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 - **platform** (optional) in query: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - **profileId** (optional) in query: Filter by profile ID. Omit for all profiles. - **accountId** (optional) in query: Filter by social account ID - **fromDate** (optional) in query: Inclusive start date (ISO 8601). Defaults to 180 days ago. - **toDate** (optional) in query: Inclusive end date (ISO 8601). Defaults to now. - **source** (optional) in query: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. - **attribution** (optional) in query: How each post's engagement is attributed to a day. "publish" (default) sums each post's lifetime total on its publish date. "received" buckets the per-day increase in engagement by the day it actually arrived (engagement-over-time), so engagement on older posts appears on the day it was gained rather than the post's publish date. ### Responses #### 200: Daily metrics and platform breakdown **Response Body:** - **dailyData** `array[object]`: - **date** `string`: No description (example: "2025-12-01") - **postCount** `integer`: No description (example: 3) - **platforms** `object`: No description (example: {"instagram":2,"twitter":1}) - **metrics** `object`: - **impressions** `integer`: No description - **reach** `integer`: No description - **likes** `integer`: No description - **comments** `integer`: No description - **shares** `integer`: No description - **saves** `integer`: No description - **clicks** `integer`: No description - **views** `integer`: No description - **platformBreakdown** `array[object]`: - **platform** `string`: No description (example: "instagram") - **postCount** `integer`: No description (example: 142) - **impressions** `integer`: No description - **reach** `integer`: No description - **likes** `integer`: No description - **comments** `integer`: No description - **shares** `integer`: No description - **saves** `integer`: No description - **clicks** `integer`: No description - **views** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") --- --- # Get Facebook Page insights API Reference 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. ## GET /v1/analytics/facebook/page-insights **Get Facebook Page insights** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the connected Facebook Page. - **metrics** (optional) in query: 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 - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: End date (YYYY-MM-DD). Defaults to today. - **metricType** (optional) in query: "total_value" (default) returns aggregated totals only. "time_series" returns daily values in the "values" array. ### Responses #### 200: Page insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: 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: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 404: Account not found --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description --- # Get follower stats API Reference Returns follower count history and growth metrics for connected social accounts. Requires analytics add-on subscription. Follower counts are refreshed once per day. ## GET /v1/accounts/follower-stats **Get follower stats** Returns follower count history and growth metrics for connected social accounts. Requires analytics add-on subscription. Follower counts are refreshed once per day. ### Parameters - **accountIds** (optional) in query: Comma-separated list of account IDs (optional, defaults to all user's accounts) - **profileId** (optional) in query: Filter by profile ID - **fromDate** (optional) in query: Start date in YYYY-MM-DD format (defaults to 30 days ago) - **toDate** (optional) in query: End date in YYYY-MM-DD format (defaults to today) - **granularity** (optional) in query: Data aggregation level ### Responses #### 200: Follower stats **Response Body:** - **accounts** `array[AccountWithFollowerStats]`: - **stats** `object`: No description - **dateRange** `object`: - **from** `string` (date-time): No description - **to** `string` (date-time): No description - **granularity** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **message** `string`: No description (example: "Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature.") - **requiresAddon** `boolean`: No description (example: true) --- # Related Schema Definitions ## AccountWithFollowerStats --- # Get GBP performance metrics API Reference 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. ## GET /v1/analytics/googlebusiness/performance **Get GBP performance metrics** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the Google Business Profile account. - **metrics** (optional) in query: 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 - **startDate** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back. - **endDate** (optional) in query: End date (YYYY-MM-DD). Defaults to today. ### Responses #### 200: Performance metrics with daily time series **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: No description - **platform** `string`: No description (example: "googlebusiness") - **dateRange** `object`: - **startDate** `string` (date): No description (example: "2026-03-01") - **endDate** `string` (date): No description (example: "2026-03-31") - **metrics** `object`: Each key is a metric name containing total and daily values. - **dataDelay** `string`: No description (example: "Data may be delayed 2-3 days") #### 400: Invalid parameters **Response Body:** - **error** `string`: No description (example: "Invalid metrics: INVALID_METRIC") - **validMetrics** `array[string]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied **Response Body:** - **error** `string`: No description (example: "Access denied to this account") --- --- # Get GBP search keywords API Reference 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. ## GET /v1/analytics/googlebusiness/search-keywords **Get GBP search keywords** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the Google Business Profile account. - **startMonth** (optional) in query: Start month (YYYY-MM). Defaults to 3 months ago. - **endMonth** (optional) in query: End month (YYYY-MM). Defaults to current month. ### Responses #### 200: Search keywords with impression counts **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: No description - **platform** `string`: No description (example: "googlebusiness") - **monthRange** `object`: - **startMonth** `string`: No description (example: "2026-01") - **endMonth** `string`: No description (example: "2026-03") - **keywords** `array[object]`: - **keyword** `string`: No description (example: "restaurant near me") - **impressions** `integer`: No description (example: 245) - **note** `string`: No description (example: "Keywords below a minimum impression threshold are excluded by Google") #### 400: Invalid parameters **Response Body:** - **error** `string`: No description (example: "Invalid startMonth format. Use YYYY-MM.") #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied **Response Body:** - **error** `string`: No description (example: "Access denied to this account") --- --- # Get Instagram insights API Reference 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. ## GET /v1/analytics/instagram/account-insights **Get Instagram insights** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the Instagram account - **metrics** (optional) in query: 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. - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: End date (YYYY-MM-DD). Defaults to today. - **metricType** (optional) in query: "total_value" (default) returns aggregated totals and supports breakdowns. "time_series" returns daily values but only works with the "reach" metric. - **breakdown** (optional) in query: 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: Account insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: Bad request (invalid parameters) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied to this account **Response Body:** - **error** `string`: No description (example: "Access denied to this account") #### 404: Account not found **Response Body:** - **error** `string`: No description (example: "Account not found") --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description --- # Get Instagram demographics API Reference 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. ## GET /v1/analytics/instagram/demographics **Get Instagram demographics** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the Instagram account - **metric** (optional) in query: "follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers. - **breakdown** (optional) in query: Comma-separated list of demographic dimensions: age, city, country, gender. Defaults to all four if omitted. - **timeframe** (optional) in query: Time period for demographic data. Defaults to "this_month". ### Responses #### 200: Demographic insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: No description (example: "instagram") - **metric** `string`: No description - one of: follower_demographics, engaged_audience_demographics - **timeframe** `string`: The timeframe used for demographic data - one of: this_week, this_month - **demographics** `object`: Object keyed by breakdown dimension (age, city, country, gender) - **note** `string`: No description (example: "Demographics show top 45 entries per dimension. Requires 100+ followers.") #### 400: Bad request (invalid parameters) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied to this account **Response Body:** - **error** `string`: No description (example: "Access denied to this account") #### 404: Account not found **Response Body:** - **error** `string`: No description (example: "Account not found") --- # Related Schema Definitions ## InstagramDemographicsResponse ### Properties - **success** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: No description - **metric** `string`: No description - one of: follower_demographics, engaged_audience_demographics - **timeframe** `string`: The timeframe used for demographic data - one of: this_week, this_month - **demographics** `object`: Object keyed by breakdown dimension (age, city, country, gender) - **note** `string`: No description --- # Get Instagram follower history API Reference 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. ## GET /v1/analytics/instagram/follower-history **Get Instagram follower history** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the Instagram account. - **metrics** (optional) in query: 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 - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: End date (YYYY-MM-DD). Defaults to today. - **metricType** (optional) in query: "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: Follower history data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: Bad request (invalid accountId / metrics / date range, or account is not an Instagram account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 404: Account not found --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description --- # Get LinkedIn aggregate stats API Reference 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. ## GET /v1/accounts/{accountId}/linkedin-aggregate-analytics **Get LinkedIn aggregate stats** 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 - **accountId** (required) in path: The ID of the LinkedIn personal account - **aggregation** (optional) in query: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY. - **startDate** (optional) in query: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics. - **endDate** (optional) in query: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted. - **metrics** (optional) in query: Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND. Omit for all. ### Responses #### 200: Aggregate analytics data **Response Body:** *One of the following:* - `LinkedInAggregateAnalyticsTotalResponse` - `LinkedInAggregateAnalyticsDailyResponse` #### 400: Invalid request **Response Body:** - **error** `string`: No description - **code** `string`: No description - **validOptions** `array[string]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description - **code** `string`: No description #### 403: Missing required LinkedIn scope **Response Body:** - **error** `string`: No description - **code** `string`: No description (example: "missing_scope") - **requiredScope** `string`: No description (example: "r_member_postAnalytics") - **action** `string`: No description (example: "reconnect") #### 404: Account not found --- # Related Schema Definitions ## LinkedInAggregateAnalyticsTotalResponse Response for TOTAL aggregation (lifetime totals) ### Properties - **accountId** `string`: No description - **platform** `string`: No description - **accountType** `string`: No description - **username** `string`: No description - **aggregation** `string`: No description - one of: TOTAL - **dateRange** `object`: - **startDate** `string`: - **endDate** `string`: - **analytics** `object`: - **impressions** `integer`: Total impressions across all posts - **reach** `integer`: Unique members reached across all posts - **reactions** `integer`: Total reactions across all posts - **comments** `integer`: Total comments across all posts - **shares** `integer`: Total reshares across all posts - **saves** `integer`: Total times posts were saved (personal accounts only) - **sends** `integer`: Total times posts were sent via LinkedIn messaging (personal accounts only) - **engagementRate** `number`: Overall engagement rate as percentage - **note** `string`: No description - **lastUpdated** `string`: No description ## LinkedInAggregateAnalyticsDailyResponse Response for DAILY aggregation (time series breakdown) ### Properties - **accountId** `string`: No description - **platform** `string`: No description - **accountType** `string`: No description - **username** `string`: No description - **aggregation** `string`: No description - one of: DAILY - **dateRange** `object`: - **startDate** `string`: - **endDate** `string`: - **analytics** `object`: Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation. - **impressions** `array`: - **reactions** `array`: - **comments** `array`: - **shares** `array`: - **saves** `array`: Daily saves (personal accounts only) - **sends** `array`: Daily sends via LinkedIn messaging (personal accounts only) - **skippedMetrics** `array`: Metrics that were skipped due to API limitations - **note** `string`: No description - **lastUpdated** `string`: No description --- # Get LinkedIn organization page aggregate analytics API Reference 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. ## GET /v1/analytics/linkedin/org-aggregate-analytics **Get LinkedIn organization page aggregate analytics** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the LinkedIn organization account. - **metrics** (optional) in query: 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 - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: End date (YYYY-MM-DD). Defaults to today. - **metricType** (optional) in query: No description ### Responses #### 200: Organization analytics data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: 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: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 403: 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: Account not found #### 412: Missing LinkedIn organization analytics scopes (r_organization_social + r_organization_followers + r_organization_admin) --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description --- # Get LinkedIn post stats API Reference 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). ## GET /v1/accounts/{accountId}/linkedin-post-analytics **Get LinkedIn post stats** 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 - **accountId** (required) in path: The ID of the LinkedIn account - **urn** (required) in query: The LinkedIn post URN ### Responses #### 200: Post analytics data **Response Body:** - **accountId** `string`: No description - **platform** `string`: No description (example: "linkedin") - **accountType** `string`: No description - one of: personal, organization - **username** `string`: No description - **postUrn** `string`: No description - **analytics** `object`: - **impressions** `integer`: Times the post was shown - **reach** `integer`: Unique members who saw the post - **likes** `integer`: Reactions on the post - **comments** `integer`: Comments on the post - **shares** `integer`: Reshares of the post - **saves** `integer`: Times the post was saved (personal accounts only; 0 for organization accounts) - **sends** `integer`: Times the post was sent via LinkedIn messaging (personal accounts only; 0 for organization accounts) - **clicks** `integer`: Clicks on the post (organization accounts only) - **views** `integer`: Video views (video posts only) - **engagementRate** `number`: Engagement rate as percentage - **lastUpdated** `string` (date-time): No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: missing_urn, invalid_urn, invalid_platform #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 403: Missing required LinkedIn scope **Response Body:** - **error** `string`: No description - **code** `string`: No description (example: "missing_scope") - **requiredScope** `string`: No description - **action** `string`: No description (example: "reconnect") #### 404: Account or post not found **Response Body:** - **error** `string`: No description - **code** `string`: No description --- --- # Get LinkedIn post reactions API Reference 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). ## GET /v1/accounts/{accountId}/linkedin-post-reactions **Get LinkedIn post reactions** 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 - **accountId** (required) in path: The ID of the LinkedIn organization account - **urn** (required) in query: The LinkedIn post URN - **limit** (optional) in query: Maximum number of reactions to return per page - **cursor** (optional) in query: Offset-based pagination start index ### Responses #### 200: Reactions with reactor profiles **Response Body:** - **accountId** `string`: No description - **platform** `string`: No description (example: "linkedin") - **accountType** `string`: No description (example: "organization") - **username** `string`: No description - **postUrn** `string`: No description - **reactions** `array[object]`: - **reactionType** `string`: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT) - **reactionLabel** `string`: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny) - **reactedAt** `string` (date-time): No description - **from** `object`: - **urn** `string`: LinkedIn person or organization URN - **name** `string`: Reactor's display name - **headline** `string`: Reactor's headline/job title - **username** `string`: LinkedIn vanity name - **profilePicture** `string`: Profile picture URL - **profileUrl** `string`: Direct link to LinkedIn profile - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: Offset for next page - **total** `integer`: Total number of reactions (when available) - **lastUpdated** `string` (date-time): No description #### 400: Invalid request or platform limitation **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: missing_urn, invalid_urn, invalid_platform, PLATFORM_LIMITATION #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 403: Missing required LinkedIn scope #### 404: Account or post not found --- --- # Get post analytics timeline API Reference 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. ## GET /v1/analytics/post-timeline **Get post analytics timeline** 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 - **postId** (required) in query: The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID. - **fromDate** (optional) in query: Start of date range (ISO 8601). Defaults to 90 days ago. - **toDate** (optional) in query: End of date range (ISO 8601). Defaults to now. ### Responses #### 200: Daily analytics timeline **Response Body:** - **postId** `string`: The postId that was requested - **timeline** `array[object]`: - **date** `string` (date): Date in YYYY-MM-DD format - **platform** `string`: Platform name (e.g. instagram, tiktok) - **platformPostId** `string`: Platform-specific post ID - **impressions** `integer`: Total impressions on this date - **reach** `integer`: Total reach on this date - **likes** `integer`: Total likes on this date - **comments** `integer`: Total comments on this date - **shares** `integer`: Total shares on this date - **saves** `integer`: Total saves on this date - **clicks** `integer`: Total clicks on this date - **views** `integer`: Total views on this date #### 400: Missing required postId parameter **Response Body:** - **error** `string`: No description (example: "Missing required parameter: postId") #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Forbidden (post belongs to another user or API key scope violation) **Response Body:** - **error** `string`: No description (example: "Forbidden") #### 404: Post not found **Response Body:** - **error** `string`: No description (example: "Post not found") --- --- # Get frequency vs engagement API Reference 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. ## GET /v1/analytics/posting-frequency **Get frequency vs engagement** 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 - **platform** (optional) in query: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - **profileId** (optional) in query: Filter by profile ID. Omit for all profiles. - **accountId** (optional) in query: Filter by social account ID. Omit for all accounts. - **source** (optional) in query: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. ### Responses #### 200: Posting frequency data **Response Body:** - **frequency** `array[object]`: - **platform** `string`: No description (example: "instagram") - **posts_per_week** `integer`: Number of posts published that week - **avg_engagement_rate** `number`: Average engagement rate as percentage (0-100) - **avg_engagement** `number`: Average raw engagement (likes+comments+shares+saves) - **weeks_count** `integer`: Number of calendar weeks observed at this frequency #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **requiresAddon** `boolean`: No description (example: true) --- --- # Get TikTok account-level insights API Reference 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/. ## GET /v1/analytics/tiktok/account-insights **Get TikTok account-level insights** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the TikTok account. - **metrics** (optional) in query: 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) - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: End date (YYYY-MM-DD). Defaults to today. - **metricType** (optional) in query: "total_value" returns the latest cumulative counter value. "time_series" returns daily values joined from AccountStats snapshots. ### Responses #### 200: Account insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: Bad request (invalid accountId / metrics / metricType / date range, or account is not a TikTok account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 404: Account not found #### 412: Missing user.info.stats scope --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description --- # Get YouTube channel-level insights API Reference 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. ## GET /v1/analytics/youtube/channel-insights **Get YouTube channel-level insights** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the YouTube account. - **metrics** (optional) in query: 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 - **since** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **until** (optional) in query: 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. - **metricType** (optional) in query: "total_value" (default) returns aggregated totals. "time_series" returns per-day values in the "values" array. ### Responses #### 200: Channel insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string` (date): No description - **until** `string` (date): No description - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description (example: "Data may be delayed up to 48 hours") #### 400: Bad request (invalid accountId / metrics / metricType / date range, or account is not a YouTube account) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. #### 404: Account not found #### 412: Missing YouTube Analytics scope **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description (example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions.") - **code** `string`: No description (example: "youtube_analytics_scope_missing") - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description (example: false) - **requiresReauthorization** `boolean`: No description (example: true) - **reauthorizeUrl** `string` (uri): URL to redirect user for reauthorization --- # Related Schema Definitions ## InstagramAccountInsightsResponse 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** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: Platform that served this response. - one of: facebook, instagram, youtube, linkedin, tiktok - **dateRange** `object`: - **since** `string`: - **until** `string`: - **metricType** `string`: No description - one of: time_series, total_value - **breakdown** `string`: Breakdown dimension used (only present when breakdown was requested) - **metrics** `object`: 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}). - **dataDelay** `string`: No description ## YouTubeScopeMissingResponse ### Properties - **success** `boolean`: No description - **error** `string`: No description - **code** `string`: No description - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: - **requiresReauthorization** `boolean`: - **reauthorizeUrl** `string`: URL to redirect user for reauthorization --- # Get YouTube daily views API Reference 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. ## GET /v1/analytics/youtube/daily-views **Get YouTube daily views** 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 - **videoId** (required) in query: The YouTube video ID (e.g., "dQw4w9WgXcQ") - **accountId** (required) in query: The Zernio account ID for the YouTube account - **startDate** (optional) in query: Start date (YYYY-MM-DD). Defaults to 30 days ago. - **endDate** (optional) in query: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency). ### Responses #### 200: Daily views breakdown **Response Body:** - **success** `boolean`: No description (example: true) - **videoId** `string`: The YouTube video ID - **durationSeconds** `integer`: Video length in seconds (from YouTube contentDetails.duration) - **dateRange** `object`: - **startDate** `string` (date): No description - **endDate** `string` (date): No description - **totalViews** `integer`: Sum of views across all days in the range - **dailyViews** `array[object]`: - **date** `string` (date): No description - **views** `integer`: No description - **estimatedMinutesWatched** `number`: No description - **averageViewDuration** `number`: Average view duration in seconds - **averageViewPercentage** `number`: Average percentage of the video watched per view. Can exceed 100 on Shorts (looping rewatches), so do not clamp it client-side. - **subscribersGained** `integer`: No description - **subscribersLost** `integer`: No description - **likes** `integer`: No description - **comments** `integer`: No description - **shares** `integer`: No description - **lastSyncedAt** `string` (date-time): When the data was last synced from YouTube - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description #### 400: Bad request (missing or invalid parameters) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied to this account **Response Body:** - **error** `string`: No description (example: "Access denied to this account") #### 412: Missing YouTube Analytics scope **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description (example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions.") - **code** `string`: No description (example: "youtube_analytics_scope_missing") - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description (example: false) - **requiresReauthorization** `boolean`: No description (example: true) - **reauthorizeUrl** `string` (uri): URL to redirect user for reauthorization #### 500: Internal server error **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description --- # Related Schema Definitions ## YouTubeDailyViewsResponse ### Properties - **success** `boolean`: No description - **videoId** `string`: The YouTube video ID - **durationSeconds** `integer`: Video length in seconds (from YouTube contentDetails.duration) - **dateRange** `object`: - **startDate** `string`: - **endDate** `string`: - **totalViews** `integer`: Sum of views across all days in the range - **dailyViews** `array`: No description - **lastSyncedAt** `string`: When the data was last synced from YouTube - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: ## YouTubeScopeMissingResponse ### Properties - **success** `boolean`: No description - **error** `string`: No description - **code** `string`: No description - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: - **requiresReauthorization** `boolean`: - **reauthorizeUrl** `string`: URL to redirect user for reauthorization --- # Get YouTube demographics API Reference 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. ## GET /v1/analytics/youtube/demographics **Get YouTube demographics** 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 - **accountId** (required) in query: The Zernio SocialAccount ID for the YouTube account - **breakdown** (optional) in query: Comma-separated list of demographic dimensions: age, gender, country. Defaults to all three if omitted. - **startDate** (optional) in query: Start date in YYYY-MM-DD format. Defaults to 90 days ago. - **endDate** (optional) in query: End date in YYYY-MM-DD format. Defaults to 3 days ago (YouTube data latency). ### Responses #### 200: Demographic insights data **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: No description (example: "youtube") - **demographics** `object`: Object keyed by breakdown dimension (age, gender, country) - **dateRange** `object`: - **startDate** `string`: No description (example: "2026-01-01") - **endDate** `string`: No description (example: "2026-03-31") - **note** `string`: No description (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.") #### 400: Bad request (invalid parameters or not a YouTube account) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied to this account **Response Body:** - **error** `string`: No description (example: "Access denied to this account") #### 404: Account not found **Response Body:** - **error** `string`: No description (example: "Account not found") #### 412: YouTube Analytics scope not granted **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description - **code** `string`: No description (example: "youtube_analytics_scope_missing") - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description (example: false) - **requiresReauthorization** `boolean`: No description (example: true) - **reauthorizeUrl** `string`: No description --- # Related Schema Definitions ## YouTubeDemographicsResponse ### Properties - **success** `boolean`: No description - **accountId** `string`: The Zernio SocialAccount ID - **platform** `string`: No description - **demographics** `object`: Object keyed by breakdown dimension (age, gender, country) - **dateRange** `object`: - **startDate** `string`: - **endDate** `string`: - **note** `string`: No description --- # Get YouTube video retention curve API Reference Returns the audience retention curve for a single YouTube video, plus the video's duration for rendering the curve on a time axis. The curve has up to 100 points (elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does not support per-day retention breakdowns. audienceWatchRatio is the absolute share of viewers watching at that point in the video and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance compares against videos of similar length (0 = worst, 0.5 = median, 1 = best). YouTube returns an empty curve for videos with very few views or before analytics processing completes (2-3 day delay). Requires yt-analytics.readonly scope (re-authorization may be needed). ## GET /v1/analytics/youtube/video-retention **Get YouTube video retention curve** Returns the audience retention curve for a single YouTube video, plus the video's duration for rendering the curve on a time axis. The curve has up to 100 points (elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does not support per-day retention breakdowns. audienceWatchRatio is the absolute share of viewers watching at that point in the video and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance compares against videos of similar length (0 = worst, 0.5 = median, 1 = best). YouTube returns an empty curve for videos with very few views or before analytics processing completes (2-3 day delay). Requires yt-analytics.readonly scope (re-authorization may be needed). ### Parameters - **videoId** (required) in query: The YouTube video ID (e.g., "dQw4w9WgXcQ") - **accountId** (required) in query: The Zernio account ID for the YouTube account - **startDate** (optional) in query: Start date (YYYY-MM-DD). Defaults to the video's publish date (lifetime curve). - **endDate** (optional) in query: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency). ### Responses #### 200: Audience retention curve **Response Body:** - **success** `boolean`: No description (example: true) - **accountId** `string`: The Zernio account ID for the YouTube account - **videoId** `string`: The YouTube video ID - **title** `string`: Video title - **publishedAt** `string` (date-time): When the video was published on YouTube - **durationSeconds** `integer`: Video length in seconds (from YouTube contentDetails.duration) - **dateRange** `object`: - **startDate** `string` (date): No description - **endDate** `string` (date): No description - **retentionCurve** `array[object]`: Up to 100 points covering the video timeline, aggregated over the date range. Empty for videos with very few views. - **elapsedVideoTimeRatio** `number`: Position in the video as a ratio (0.01-1.0, exclusive end of each interval) - **audienceWatchRatio** `number`: Absolute share of viewers watching at this point. Can exceed 1 (rewinds/looping, common on Shorts). - **relativeRetentionPerformance** `number`: Retention vs videos of similar length (0 = worst, 0.5 = median, 1 = best) - **startedWatching** `integer`: Viewers who started watching in this segment - **stoppedWatching** `integer`: Viewers who stopped watching in this segment - **totalSegmentImpressions** `integer`: Total views of this segment, including rewatches - **note** `string`: Present only when the curve is empty, explaining why - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description #### 400: Bad request (missing or invalid parameters) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans. **Response Body:** - **error** `string`: No description (example: "Analytics add-on required") - **code** `string`: No description (example: "analytics_addon_required") #### 403: Access denied to this account **Response Body:** - **error** `string`: No description (example: "Access denied to this account") #### 404: Video not found, or it does not belong to this YouTube channel **Response Body:** - **error** `string`: No description (example: "Video not found on this YouTube channel") - **type** `string`: No description (example: "not_found") - **code** `string`: No description (example: "video_not_found") - **param** `string`: No description (example: "videoId") #### 412: Missing YouTube Analytics scope **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description (example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions.") - **code** `string`: No description (example: "youtube_analytics_scope_missing") - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: No description (example: false) - **requiresReauthorization** `boolean`: No description (example: true) - **reauthorizeUrl** `string` (uri): URL to redirect user for reauthorization #### 500: Internal server error **Response Body:** - **success** `boolean`: No description (example: false) - **error** `string`: No description --- # Related Schema Definitions ## YouTubeVideoRetentionResponse ### Properties - **success** `boolean`: No description - **accountId** `string`: The Zernio account ID for the YouTube account - **videoId** `string`: The YouTube video ID - **title** `string`: Video title - **publishedAt** `string`: When the video was published on YouTube - **durationSeconds** `integer`: Video length in seconds (from YouTube contentDetails.duration) - **dateRange** `object`: - **startDate** `string`: - **endDate** `string`: - **retentionCurve** `array`: Up to 100 points covering the video timeline, aggregated over the date range. Empty for videos with very few views. - **note** `string`: Present only when the curve is empty, explaining why - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: ## YouTubeScopeMissingResponse ### Properties - **success** `boolean`: No description - **error** `string`: No description - **code** `string`: No description - **scopeStatus** `object`: - **hasAnalyticsScope** `boolean`: - **requiresReauthorization** `boolean`: - **reauthorizeUrl** `string`: URL to redirect user for reauthorization --- # Create key API Reference Creates a new API key with an optional expiry. The full key value is only returned once in the response. ## GET /v1/api-keys **List keys** Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value. ### Responses #### 200: API keys **Response Body:** - **apiKeys** `array[ApiKey]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/api-keys **Create key** Creates a new API key with an optional expiry. The full key value is only returned once in the response. ### Request Body - **name** (required) `string`: No description - **expiresIn** `integer`: Days until expiry - **scope** `string`: 'full' grants access to all profiles (default), 'profiles' restricts to specific profiles - one of: full, profiles - **profileIds** `array`: Profile IDs this key can access. Required when scope is 'profiles'. - **permission** `string`: 'read-write' allows all operations (default), 'read' restricts to GET requests only - one of: read-write, read ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **apiKey**: `ApiKey` - See schema definition #### 400: Invalid request (missing name #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## ApiKey ### Properties - **id** `string`: No description - **name** `string`: No description - **keyPreview** `string`: No description - **expiresAt** `string`: No description - **createdAt** `string`: No description - **key** `string`: Returned only once, on creation - **scope** `string`: 'full' grants access to all profiles, 'profiles' restricts to specific profiles - one of: full, profiles (default: full) - **profileIds** `array`: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'. - **permission** `string`: 'read-write' allows all operations, 'read' restricts to GET requests only - one of: read-write, read (default: read-write) --- # Delete key API Reference Permanently revokes and deletes an API key. ## DELETE /v1/api-keys/{keyId} **Delete key** Permanently revokes and deletes an API key. ### Parameters - **keyId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List keys API Reference Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value. ## GET /v1/api-keys **List keys** Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value. ### Responses #### 200: API keys **Response Body:** - **apiKeys** `array[ApiKey]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/api-keys **Create key** Creates a new API key with an optional expiry. The full key value is only returned once in the response. ### Request Body - **name** (required) `string`: No description - **expiresIn** `integer`: Days until expiry - **scope** `string`: 'full' grants access to all profiles (default), 'profiles' restricts to specific profiles - one of: full, profiles - **profileIds** `array`: Profile IDs this key can access. Required when scope is 'profiles'. - **permission** `string`: 'read-write' allows all operations (default), 'read' restricts to GET requests only - one of: read-write, read ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **apiKey**: `ApiKey` - See schema definition #### 400: Invalid request (missing name #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## ApiKey ### Properties - **id** `string`: No description - **name** `string`: No description - **keyPreview** `string`: No description - **expiresAt** `string`: No description - **createdAt** `string`: No description - **key** `string`: Returned only once, on creation - **scope** `string`: 'full' grants access to all profiles, 'profiles' restricts to specific profiles - one of: full, profiles (default: full) - **profileIds** `array`: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'. - **permission** `string`: 'read-write' allows all operations, 'read' restricts to GET requests only - one of: read-write, read (default: read-write) --- # Add recipients to a broadcast API Reference Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters. ## GET /v1/broadcasts/{broadcastId}/recipients **List broadcast recipients** Returns recipients for a broadcast with individual delivery status. Filter by status. ### Parameters - **broadcastId** (required) in path: No description - **status** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Recipients list with delivery status **Response Body:** - **success** `boolean`: No description - **recipients** `array[object]`: - **id** `string`: No description - **contactId** `string`: No description - **channelId** `string`: No description - **platformIdentifier** `string`: No description - **contactName** `string`: No description - **status** `string`: No description - one of: pending, sent, delivered, read, failed - **messageId** `string`: No description - **error** `string`: No description - **errorCode** `integer`: Meta WhatsApp error code (e.g. 131049 for antispam, 131021 for invalid phone, 131026 for re-engagement required). Only populated for status=failed. - **errorExplanation** `string`: Plain-language translation of errorCode (e.g. for 131026, that the recipient has likely opted out of marketing messages). Null for unmapped codes; fall back to error. - **sentAt** `string` (date-time): No description - **deliveredAt** `string` (date-time): No description - **readAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## POST /v1/broadcasts/{broadcastId}/recipients **Add recipients to a broadcast** Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **contactIds** `array`: Specific contact IDs to add - **phones** `array`: Raw phone numbers (auto-creates contacts). Useful for WhatsApp/Telegram manual entry - **useSegment** `boolean`: Auto-populate from broadcast segment filters ### Responses #### 200: Recipients added **Response Body:** - **success** `boolean`: No description - **added** `integer`: Number of recipients successfully added - **skipped** `integer`: Number skipped (duplicates or missing channels) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Cancel broadcast API Reference Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected. ## POST /v1/broadcasts/{broadcastId}/cancel **Cancel broadcast** Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast cancelled **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **status** `string`: No description #### 400: Cannot cancel in current status #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Create broadcast draft API Reference Create a broadcast in draft status. Add recipients and then send or schedule it. ## GET /v1/broadcasts **List broadcasts** Returns broadcasts with delivery stats. Filter by status, platform, or profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **status** (optional) in query: No description - **platform** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Broadcasts list **Response Body:** - **success** `boolean`: No description - **broadcasts** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountName** `string`: Display name of the sending account - **status** `string`: No description - one of: draft, scheduled, sending, completed, failed, cancelled - **messagePreview** `string`: Template name or message text snippet - **scheduledAt** `string` (date-time): No description - **startedAt** `string` (date-time): No description - **completedAt** `string` (date-time): No description - **recipientCount** `integer`: No description - **sentCount** `integer`: No description - **deliveredCount** `integer`: No description - **readCount** `integer`: No description - **failedCount** `integer`: No description - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/broadcasts **Create broadcast draft** Create a broadcast in draft status. Add recipients and then send or schedule it. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: No description - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp - **name** (required) `string`: No description - **description** `string`: No description - **message** `object`: No description - **template** `object`: WhatsApp template (required when platform is whatsapp) - **segmentFilters** `object`: No description ### Responses #### 200: Broadcast created **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Delete broadcast API Reference Permanently delete a broadcast. Only drafts can be deleted. ## GET /v1/broadcasts/{broadcastId} **Get broadcast details** Returns a broadcast with its full configuration and delivery stats. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast details with stats **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **segmentFilters** `object`: - **tags** `array[string]`: - **status** `string`: No description - one of: draft, scheduled, sending, completed, failed, cancelled - **scheduledAt** `string` (date-time): No description - **startedAt** `string` (date-time): No description - **completedAt** `string` (date-time): No description - **recipientCount** `integer`: No description - **sentCount** `integer`: No description - **deliveredCount** `integer`: No description - **readCount** `integer`: No description - **failedCount** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/broadcasts/{broadcastId} **Update broadcast** Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **message** `object`: Generic message payload (used for non-WhatsApp platforms). - **template** `object`: WhatsApp template payload (used when platform is `whatsapp`). - **segmentFilters** `object`: Recipient segment filters (tags, channels, subscription state). ### Responses #### 200: Broadcast updated **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/broadcasts/{broadcastId} **Delete broadcast** Permanently delete a broadcast. Only drafts can be deleted. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Get broadcast details API Reference Returns a broadcast with its full configuration and delivery stats. ## GET /v1/broadcasts/{broadcastId} **Get broadcast details** Returns a broadcast with its full configuration and delivery stats. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast details with stats **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **segmentFilters** `object`: - **tags** `array[string]`: - **status** `string`: No description - one of: draft, scheduled, sending, completed, failed, cancelled - **scheduledAt** `string` (date-time): No description - **startedAt** `string` (date-time): No description - **completedAt** `string` (date-time): No description - **recipientCount** `integer`: No description - **sentCount** `integer`: No description - **deliveredCount** `integer`: No description - **readCount** `integer`: No description - **failedCount** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/broadcasts/{broadcastId} **Update broadcast** Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **message** `object`: Generic message payload (used for non-WhatsApp platforms). - **template** `object`: WhatsApp template payload (used when platform is `whatsapp`). - **segmentFilters** `object`: Recipient segment filters (tags, channels, subscription state). ### Responses #### 200: Broadcast updated **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/broadcasts/{broadcastId} **Delete broadcast** Permanently delete a broadcast. Only drafts can be deleted. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List broadcast recipients API Reference Returns recipients for a broadcast with individual delivery status. Filter by status. ## GET /v1/broadcasts/{broadcastId}/recipients **List broadcast recipients** Returns recipients for a broadcast with individual delivery status. Filter by status. ### Parameters - **broadcastId** (required) in path: No description - **status** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Recipients list with delivery status **Response Body:** - **success** `boolean`: No description - **recipients** `array[object]`: - **id** `string`: No description - **contactId** `string`: No description - **channelId** `string`: No description - **platformIdentifier** `string`: No description - **contactName** `string`: No description - **status** `string`: No description - one of: pending, sent, delivered, read, failed - **messageId** `string`: No description - **error** `string`: No description - **errorCode** `integer`: Meta WhatsApp error code (e.g. 131049 for antispam, 131021 for invalid phone, 131026 for re-engagement required). Only populated for status=failed. - **errorExplanation** `string`: Plain-language translation of errorCode (e.g. for 131026, that the recipient has likely opted out of marketing messages). Null for unmapped codes; fall back to error. - **sentAt** `string` (date-time): No description - **deliveredAt** `string` (date-time): No description - **readAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## POST /v1/broadcasts/{broadcastId}/recipients **Add recipients to a broadcast** Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **contactIds** `array`: Specific contact IDs to add - **phones** `array`: Raw phone numbers (auto-creates contacts). Useful for WhatsApp/Telegram manual entry - **useSegment** `boolean`: Auto-populate from broadcast segment filters ### Responses #### 200: Recipients added **Response Body:** - **success** `boolean`: No description - **added** `integer`: Number of recipients successfully added - **skipped** `integer`: Number skipped (duplicates or missing channels) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List broadcasts API Reference Returns broadcasts with delivery stats. Filter by status, platform, or profile. ## GET /v1/broadcasts **List broadcasts** Returns broadcasts with delivery stats. Filter by status, platform, or profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **status** (optional) in query: No description - **platform** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Broadcasts list **Response Body:** - **success** `boolean`: No description - **broadcasts** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountName** `string`: Display name of the sending account - **status** `string`: No description - one of: draft, scheduled, sending, completed, failed, cancelled - **messagePreview** `string`: Template name or message text snippet - **scheduledAt** `string` (date-time): No description - **startedAt** `string` (date-time): No description - **completedAt** `string` (date-time): No description - **recipientCount** `integer`: No description - **sentCount** `integer`: No description - **deliveredCount** `integer`: No description - **readCount** `integer`: No description - **failedCount** `integer`: No description - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/broadcasts **Create broadcast draft** Create a broadcast in draft status. Add recipients and then send or schedule it. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: No description - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp - **name** (required) `string`: No description - **description** `string`: No description - **message** `object`: No description - **template** `object`: WhatsApp template (required when platform is whatsapp) - **segmentFilters** `object`: No description ### Responses #### 200: Broadcast created **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Schedule broadcast for later API Reference Schedule a draft broadcast to be sent at a future date and time. ## POST /v1/broadcasts/{broadcastId}/schedule **Schedule broadcast for later** Schedule a draft broadcast to be sent at a future date and time. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **scheduledAt** (required) `string`: No description ### Responses #### 200: Broadcast scheduled **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **status** `string`: No description - **scheduledAt** `string` (date-time): No description #### 400: Invalid date or status #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Send broadcast now API Reference Immediately start sending a draft broadcast to its recipients. ## POST /v1/broadcasts/{broadcastId}/send **Send broadcast now** Immediately start sending a draft broadcast to its recipients. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast sending started **Response Body:** - **success** `boolean`: No description - **status** `string`: Current broadcast status after processing first batch - one of: sending, completed, failed - **sent** `integer`: Recipients sent in this batch - **failed** `integer`: Recipients failed in this batch - **recipientCount** `integer`: Total recipient count #### 400: Invalid status or no recipients #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Update broadcast API Reference Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated. ## GET /v1/broadcasts/{broadcastId} **Get broadcast details** Returns a broadcast with its full configuration and delivery stats. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast details with stats **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **segmentFilters** `object`: - **tags** `array[string]`: - **status** `string`: No description - one of: draft, scheduled, sending, completed, failed, cancelled - **scheduledAt** `string` (date-time): No description - **startedAt** `string` (date-time): No description - **completedAt** `string` (date-time): No description - **recipientCount** `integer`: No description - **sentCount** `integer`: No description - **deliveredCount** `integer`: No description - **readCount** `integer`: No description - **failedCount** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/broadcasts/{broadcastId} **Update broadcast** Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated. ### Parameters - **broadcastId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **message** `object`: Generic message payload (used for non-WhatsApp platforms). - **template** `object`: WhatsApp template payload (used when platform is `whatsapp`). - **segmentFilters** `object`: Recipient segment filters (tags, channels, subscription state). ### Responses #### 200: Broadcast updated **Response Body:** - **success** `boolean`: No description - **broadcast** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/broadcasts/{broadcastId} **Delete broadcast** Permanently delete a broadcast. Only drafts can be deleted. ### Parameters - **broadcastId** (required) in path: No description ### Responses #### 200: Broadcast deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Create comment-to-DM automation API Reference Create a keyword-triggered DM automation on an Instagram or Facebook account. When someone comments a matching keyword (or, with `trigger: story_reply`, replies to your Instagram story with one), they automatically receive a DM. Triggers (`trigger`): * `comment` (default): fires on keyword comments on a post or reel. * `story_reply`: fires when someone replies to your Instagram story with a keyword, and answers them with a DM. Set `platformPostId` to a story media id to scope to one story, or omit it to match replies to any story. Targeting (comment trigger): * Per-post: set `platformPostId` to scope to one specific post (only one active per-post automation is allowed per post). * Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation evaluates every comment on every post on the account. You can stack unlimited account-wide automations, each with its own keyword set, and they all run independently. Per-post automations take priority on their post. Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and clickers optionally tagged (`clickTag`) for segmentation. Stats returned include delivered, read, and link clicks. ## GET /v1/comment-automations **List comment-to-DM automations** List all comment-to-DM automations for a profile. Returns automations with their stats. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles ### Responses #### 200: Automations list **Response Body:** - **success** `boolean`: No description - **automations** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: instagram, facebook - **trigger** `string`: No description - one of: comment, story_reply - **accountId** `string`: No description - **platformPostId** `string`: No description - **postTitle** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: Whether link buttons in the DM are wrapped in a tracked redirect to count clicks. - **clickTag** `string`: Tag applied to a contact when they click a tracked link. - **isActive** `boolean`: No description - **stats** `object`: - **triggered** `integer`: No description - **dmsSent** `integer`: No description - **dmsFailed** `integer`: No description - **uniqueContacts** `integer`: No description - **trackedSends** `integer`: DMs sent with a trackable (wrapped) link. CTR denominator: divide clicks by this, not dmsSent. Lags dmsSent for campaigns that predate click tracking. - **linkClicks** `integer`: Total clicks on tracked links (bots/prefetch excluded). - **uniqueClicks** `integer`: Distinct people who clicked a tracked link. - **delivered** `integer`: DMs confirmed delivered (Messenger; IG emits no delivery receipt). - **read** `integer`: DMs confirmed read (IG messaging_seen / Messenger message_reads). - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/comment-automations **Create comment-to-DM automation** Create a keyword-triggered DM automation on an Instagram or Facebook account. When someone comments a matching keyword (or, with `trigger: story_reply`, replies to your Instagram story with one), they automatically receive a DM. Triggers (`trigger`): * `comment` (default): fires on keyword comments on a post or reel. * `story_reply`: fires when someone replies to your Instagram story with a keyword, and answers them with a DM. Set `platformPostId` to a story media id to scope to one story, or omit it to match replies to any story. Targeting (comment trigger): * Per-post: set `platformPostId` to scope to one specific post (only one active per-post automation is allowed per post). * Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation evaluates every comment on every post on the account. You can stack unlimited account-wide automations, each with its own keyword set, and they all run independently. Per-post automations take priority on their post. Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and clickers optionally tagged (`clickTag`) for segmentation. Stats returned include delivered, read, and link clicks. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: Instagram or Facebook account ID - **trigger** `string`: What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story). - one of: comment, story_reply - **platformPostId** `string`: Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation. - **postId** `string`: Zernio post ID. Required only when also targeting a specific post via platformPostId. - **postTitle** `string`: Post content snippet for display - **name** (required) `string`: Automation label - **keywords** `array`: Trigger keywords (empty = any comment triggers) - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** (required) `string`: DM text to send to commenter. Max 640 chars when buttons are set, otherwise ~1000. - **buttons** `array`: Optional inline DM buttons (1-3). Phone buttons are Facebook-only. Omit or pass [] for a plain-text DM. - **commentReply** `string`: Optional public reply to the comment - **linkTracking** `boolean`: Wrap link buttons in the DM in a tracked redirect so clicks are counted (Link Clicks / CTR). Pass false to send links exactly as written. Defaults to on. - **clickTag** `string`: Optional tag applied to a contact when they click a tracked link (requires linkTracking). Lets you segment clickers for broadcasts/sequences. ### Responses #### 200: Automation created **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **platformPostId** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: No description - **clickTag** `string`: No description - **isActive** `boolean`: No description - **stats** `object`: - **totalTriggered** `integer`: No description - **totalSent** `integer`: No description - **totalFailed** `integer`: No description - **createdAt** `string` (date-time): No description #### 400: Validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Active per-post automation already exists for this platformPostId. Does not apply to account-wide automations. --- # Related Schema Definitions ## DmButton A single inline button rendered inside an auto-DM via Meta's button_template. Up to 3 buttons per automation. `url` and `postback` work on Instagram and Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes the button_template text and must be 640 characters or less. ### Properties - **type** (required) `string`: No description - one of: url, postback, phone - **title** (required) `string`: Button label (20 chars max) (max: 20) - **url** `string`: Target URL (required when type is url) - **payload** `string`: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) - **phone** `string`: Phone number, e.g. +14155551234 (required when type is phone; Facebook only) --- # Delete automation API Reference Permanently delete an automation and all its trigger logs. ## GET /v1/comment-automations/{automationId} **Get automation details** Returns an automation with its configuration, stats, and recent trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation details with stats and recent trigger logs **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **accountId** `string`: No description - **platformPostId** `string`: No description - **postId** `string`: No description - **postTitle** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: No description - **clickTag** `string`: No description - **isActive** `boolean`: No description - **stats** `object`: - **totalTriggered** `integer`: No description - **totalSent** `integer`: No description - **totalFailed** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **logs** `array[object]`: - **id** `string`: No description - **commentId** `string`: No description - **commenterId** `string`: No description - **commenterName** `string`: No description - **commentText** `string`: No description - **status** `string`: DM outcome - one of: sent, failed, skipped - **error** `string`: DM error message if status is failed - **commentReplyStatus** `string`: 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). - one of: sent, failed, skipped - **commentReplyError** `string`: Public-reply error message if commentReplyStatus is failed - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/comment-automations/{automationId} **Update automation settings** Update an automation's keywords, DM message, inline buttons, comment reply, or active status. Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new one if you're changing it, otherwise the stored one) must be 640 characters or less. ### Parameters - **automationId** (required) in path: No description ### Request Body - **name** `string`: No description - **keywords** `array`: No description - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array`: Inline DM buttons (1-3). Pass [] to clear all buttons. - **commentReply** `string`: No description - **linkTracking** `boolean`: Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched. - **clickTag** `string`: Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it. - **isActive** `boolean`: No description ### Responses #### 200: Automation updated **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **isActive** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/comment-automations/{automationId} **Delete automation** Permanently delete an automation and all its trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## DmButton A single inline button rendered inside an auto-DM via Meta's button_template. Up to 3 buttons per automation. `url` and `postback` work on Instagram and Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes the button_template text and must be 640 characters or less. ### Properties - **type** (required) `string`: No description - one of: url, postback, phone - **title** (required) `string`: Button label (20 chars max) (max: 20) - **url** `string`: Target URL (required when type is url) - **payload** `string`: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) - **phone** `string`: Phone number, e.g. +14155551234 (required when type is phone; Facebook only) --- # Get automation details API Reference Returns an automation with its configuration, stats, and recent trigger logs. ## GET /v1/comment-automations/{automationId} **Get automation details** Returns an automation with its configuration, stats, and recent trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation details with stats and recent trigger logs **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **accountId** `string`: No description - **platformPostId** `string`: No description - **postId** `string`: No description - **postTitle** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: No description - **clickTag** `string`: No description - **isActive** `boolean`: No description - **stats** `object`: - **totalTriggered** `integer`: No description - **totalSent** `integer`: No description - **totalFailed** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **logs** `array[object]`: - **id** `string`: No description - **commentId** `string`: No description - **commenterId** `string`: No description - **commenterName** `string`: No description - **commentText** `string`: No description - **status** `string`: DM outcome - one of: sent, failed, skipped - **error** `string`: DM error message if status is failed - **commentReplyStatus** `string`: 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). - one of: sent, failed, skipped - **commentReplyError** `string`: Public-reply error message if commentReplyStatus is failed - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/comment-automations/{automationId} **Update automation settings** Update an automation's keywords, DM message, inline buttons, comment reply, or active status. Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new one if you're changing it, otherwise the stored one) must be 640 characters or less. ### Parameters - **automationId** (required) in path: No description ### Request Body - **name** `string`: No description - **keywords** `array`: No description - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array`: Inline DM buttons (1-3). Pass [] to clear all buttons. - **commentReply** `string`: No description - **linkTracking** `boolean`: Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched. - **clickTag** `string`: Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it. - **isActive** `boolean`: No description ### Responses #### 200: Automation updated **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **isActive** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/comment-automations/{automationId} **Delete automation** Permanently delete an automation and all its trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## DmButton A single inline button rendered inside an auto-DM via Meta's button_template. Up to 3 buttons per automation. `url` and `postback` work on Instagram and Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes the button_template text and must be 640 characters or less. ### Properties - **type** (required) `string`: No description - one of: url, postback, phone - **title** (required) `string`: Button label (20 chars max) (max: 20) - **url** `string`: Target URL (required when type is url) - **payload** `string`: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) - **phone** `string`: Phone number, e.g. +14155551234 (required when type is phone; Facebook only) --- # List automation logs API Reference Paginated list of every comment that triggered this automation, with send status and commenter info. ## GET /v1/comment-automations/{automationId}/logs **List automation logs** Paginated list of every comment that triggered this automation, with send status and commenter info. ### Parameters - **automationId** (required) in path: No description - **status** (optional) in query: Filter by result status - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Trigger logs with pagination **Response Body:** - **success** `boolean`: No description - **logs** `array[object]`: - **id** `string`: No description - **commentId** `string`: No description - **commenterId** `string`: No description - **commenterName** `string`: No description - **commentText** `string`: No description - **status** `string`: DM outcome - one of: sent, failed, skipped - **error** `string`: DM error message if status is failed - **commentReplyStatus** `string`: 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). - one of: sent, failed, skipped - **commentReplyError** `string`: Public-reply error message if commentReplyStatus is failed - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List comment-to-DM automations API Reference List all comment-to-DM automations for a profile. Returns automations with their stats. ## GET /v1/comment-automations **List comment-to-DM automations** List all comment-to-DM automations for a profile. Returns automations with their stats. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles ### Responses #### 200: Automations list **Response Body:** - **success** `boolean`: No description - **automations** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - one of: instagram, facebook - **trigger** `string`: No description - one of: comment, story_reply - **accountId** `string`: No description - **platformPostId** `string`: No description - **postTitle** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: Whether link buttons in the DM are wrapped in a tracked redirect to count clicks. - **clickTag** `string`: Tag applied to a contact when they click a tracked link. - **isActive** `boolean`: No description - **stats** `object`: - **triggered** `integer`: No description - **dmsSent** `integer`: No description - **dmsFailed** `integer`: No description - **uniqueContacts** `integer`: No description - **trackedSends** `integer`: DMs sent with a trackable (wrapped) link. CTR denominator: divide clicks by this, not dmsSent. Lags dmsSent for campaigns that predate click tracking. - **linkClicks** `integer`: Total clicks on tracked links (bots/prefetch excluded). - **uniqueClicks** `integer`: Distinct people who clicked a tracked link. - **delivered** `integer`: DMs confirmed delivered (Messenger; IG emits no delivery receipt). - **read** `integer`: DMs confirmed read (IG messaging_seen / Messenger message_reads). - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/comment-automations **Create comment-to-DM automation** Create a keyword-triggered DM automation on an Instagram or Facebook account. When someone comments a matching keyword (or, with `trigger: story_reply`, replies to your Instagram story with one), they automatically receive a DM. Triggers (`trigger`): * `comment` (default): fires on keyword comments on a post or reel. * `story_reply`: fires when someone replies to your Instagram story with a keyword, and answers them with a DM. Set `platformPostId` to a story media id to scope to one story, or omit it to match replies to any story. Targeting (comment trigger): * Per-post: set `platformPostId` to scope to one specific post (only one active per-post automation is allowed per post). * Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation evaluates every comment on every post on the account. You can stack unlimited account-wide automations, each with its own keyword set, and they all run independently. Per-post automations take priority on their post. Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and clickers optionally tagged (`clickTag`) for segmentation. Stats returned include delivered, read, and link clicks. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: Instagram or Facebook account ID - **trigger** `string`: What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story). - one of: comment, story_reply - **platformPostId** `string`: Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation. - **postId** `string`: Zernio post ID. Required only when also targeting a specific post via platformPostId. - **postTitle** `string`: Post content snippet for display - **name** (required) `string`: Automation label - **keywords** `array`: Trigger keywords (empty = any comment triggers) - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** (required) `string`: DM text to send to commenter. Max 640 chars when buttons are set, otherwise ~1000. - **buttons** `array`: Optional inline DM buttons (1-3). Phone buttons are Facebook-only. Omit or pass [] for a plain-text DM. - **commentReply** `string`: Optional public reply to the comment - **linkTracking** `boolean`: Wrap link buttons in the DM in a tracked redirect so clicks are counted (Link Clicks / CTR). Pass false to send links exactly as written. Defaults to on. - **clickTag** `string`: Optional tag applied to a contact when they click a tracked link (requires linkTracking). Lets you segment clickers for broadcasts/sequences. ### Responses #### 200: Automation created **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **platformPostId** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: No description - **clickTag** `string`: No description - **isActive** `boolean`: No description - **stats** `object`: - **totalTriggered** `integer`: No description - **totalSent** `integer`: No description - **totalFailed** `integer`: No description - **createdAt** `string` (date-time): No description #### 400: Validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Active per-post automation already exists for this platformPostId. Does not apply to account-wide automations. --- # Related Schema Definitions ## DmButton A single inline button rendered inside an auto-DM via Meta's button_template. Up to 3 buttons per automation. `url` and `postback` work on Instagram and Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes the button_template text and must be 640 characters or less. ### Properties - **type** (required) `string`: No description - one of: url, postback, phone - **title** (required) `string`: Button label (20 chars max) (max: 20) - **url** `string`: Target URL (required when type is url) - **payload** `string`: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) - **phone** `string`: Phone number, e.g. +14155551234 (required when type is phone; Facebook only) --- # Update automation settings API Reference Update an automation's keywords, DM message, inline buttons, comment reply, or active status. Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new one if you're changing it, otherwise the stored one) must be 640 characters or less. ## GET /v1/comment-automations/{automationId} **Get automation details** Returns an automation with its configuration, stats, and recent trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation details with stats and recent trigger logs **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **platform** `string`: No description - **trigger** `string`: No description - one of: comment, story_reply - **accountId** `string`: No description - **platformPostId** `string`: No description - **postId** `string`: No description - **postTitle** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **linkTracking** `boolean`: No description - **clickTag** `string`: No description - **isActive** `boolean`: No description - **stats** `object`: - **totalTriggered** `integer`: No description - **totalSent** `integer`: No description - **totalFailed** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **logs** `array[object]`: - **id** `string`: No description - **commentId** `string`: No description - **commenterId** `string`: No description - **commenterName** `string`: No description - **commentText** `string`: No description - **status** `string`: DM outcome - one of: sent, failed, skipped - **error** `string`: DM error message if status is failed - **commentReplyStatus** `string`: 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). - one of: sent, failed, skipped - **commentReplyError** `string`: Public-reply error message if commentReplyStatus is failed - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/comment-automations/{automationId} **Update automation settings** Update an automation's keywords, DM message, inline buttons, comment reply, or active status. Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new one if you're changing it, otherwise the stored one) must be 640 characters or less. ### Parameters - **automationId** (required) in path: No description ### Request Body - **name** `string`: No description - **keywords** `array`: No description - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array`: Inline DM buttons (1-3). Pass [] to clear all buttons. - **commentReply** `string`: No description - **linkTracking** `boolean`: Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched. - **clickTag** `string`: Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it. - **isActive** `boolean`: No description ### Responses #### 200: Automation updated **Response Body:** - **success** `boolean`: No description - **automation** `object`: - **id** `string`: No description - **name** `string`: No description - **keywords** `array[string]`: - **matchMode** `string`: No description - one of: exact, contains - **dmMessage** `string`: No description - **buttons** `array[DmButton]`: Inline DM buttons (up to 3). Omitted when none are set. - **commentReply** `string`: No description - **isActive** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/comment-automations/{automationId} **Delete automation** Permanently delete an automation and all its trigger logs. ### Parameters - **automationId** (required) in path: No description ### Responses #### 200: Automation deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## DmButton A single inline button rendered inside an auto-DM via Meta's button_template. Up to 3 buttons per automation. `url` and `postback` work on Instagram and Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes the button_template text and must be 640 characters or less. ### Properties - **type** (required) `string`: No description - one of: url, postback, phone - **title** (required) `string`: Button label (20 chars max) (max: 20) - **url** `string`: Target URL (required when type is url) - **payload** `string`: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) - **phone** `string`: Phone number, e.g. +14155551234 (required when type is phone; Facebook only) --- # Delete comment API Reference Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. Requires accountId and commentId query parameters. ## GET /v1/inbox/comments/{postId} **Get post comments** Fetch comments for a specific post. Requires accountId query parameter. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **subreddit** (optional) in query: (Reddit only) Subreddit name - **limit** (optional) in query: Maximum number of comments to return - **cursor** (optional) in query: Pagination cursor - **commentId** (optional) in query: (Reddit only) Get replies to a specific comment ### Responses #### 200: Comments for the post **Response Body:** - **status** `string`: No description - **comments** `array[object]`: - **id** `string`: No description - **message** `string`: No description - **createdTime** `string` (date-time): No description - **from** `object`: - **id** `string`: No description - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **isOwner** `boolean`: No description - **verifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X comments. - one of: blue, government, business, none - **likeCount** `integer`: No description - **replyCount** `integer`: No description - **platform** `string`: The platform this comment is from - **url** `string`: Direct link to the comment on the platform (if available) - **replies** `array[object]`: Type: `object` - **canReply** `boolean`: No description - **canDelete** `boolean`: No description - **canHide** `boolean`: Whether this comment can be hidden (Facebook - **canLike** `boolean`: Whether this comment can be liked (Facebook - **isHidden** `boolean`: Whether the comment is currently hidden - **isLiked** `boolean`: Whether the current user has liked this comment - **likeUri** `string`: Bluesky like URI for unliking - **cid** `string`: Bluesky content identifier - **parentId** `string`: Parent comment ID for nested replies - **rootUri** `string`: Bluesky root post URI - **rootCid** `string`: Bluesky root post CID - **post** `object`: (Reddit only) Metadata for the target post, returned alongside the comments in Reddit's single round-trip. Lets integrators render a preview of the post the user is commenting on without an additional request. Absent for non-Reddit platforms and when the upstream response is missing the post listing (deleted post, malformed response). - **id** `string`: Reddit post base36 id (e.g. "1tjtj26") - **fullname** `string`: Fullname with type prefix (e.g. "t3_1tjtj26") - **title** `string`: No description - **selftext** `string`: Body text for self-posts (empty for link posts) - **author** `string`: Reddit username - **subreddit** `string`: Subreddit name - **permalink** `string`: Absolute URL to the post on reddit.com - **url** `string`: For link posts - **score** `integer`: Net upvotes (upvotes minus downvotes) - **numComments** `integer`: No description - **createdUtc** `integer`: Unix timestamp in seconds - **over18** `boolean`: No description - **stickied** `boolean`: No description - **flairText** `string`: Link flair text if any - **isGallery** `boolean`: True if the post is a Reddit gallery (multiple images) - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description - **meta** `object`: - **platform** `string`: No description - **postId** `string`: No description - **accountId** `string`: No description - **subreddit** `string`: (Reddit only) Subreddit name - **lastUpdated** `string` (date-time): No description - **adComments** `object`: (Facebook/Instagram only) Present when this post has no organic comments but is a boosted post — the engagement lives on the ad. Use the ad-comments endpoint instead. - **adId** `string`: Internal Zernio ad ID - **adCommentsUrl** `string`: Path to fetch the ad's comments (GET /v1/ads/{adId}/comments) #### 400: Invalid request, or the postId belongs to a Meta ad creative / ad ID rather than an organic post (code USE_AD_COMMENTS_ENDPOINT — response includes `adId` and `adCommentsUrl`). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/comments/{postId} **Reply to comment** Post a reply to a post or specific comment. Requires accountId in request body. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. ### Request Body - **accountId** (required) `string`: No description - **message** (required) `string`: No description - **commentId** `string`: Reply to specific comment (optional) - **parentCid** `string`: (Bluesky only) Parent content identifier - **rootUri** `string`: (Bluesky only) Root post URI - **rootCid** `string`: (Bluesky only) Root post CID ### Responses #### 200: Reply posted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **commentId** `string`: No description - **isReply** `boolean`: No description - **cid** `string`: Bluesky CID #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId} **Delete comment** Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. Requires accountId and commentId query parameters. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **commentId** (required) in query: No description ### Responses #### 200: Comment deleted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **message** `string`: No description #### 400: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Get post comments API Reference Fetch comments for a specific post. Requires accountId query parameter. ## GET /v1/inbox/comments/{postId} **Get post comments** Fetch comments for a specific post. Requires accountId query parameter. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **subreddit** (optional) in query: (Reddit only) Subreddit name - **limit** (optional) in query: Maximum number of comments to return - **cursor** (optional) in query: Pagination cursor - **commentId** (optional) in query: (Reddit only) Get replies to a specific comment ### Responses #### 200: Comments for the post **Response Body:** - **status** `string`: No description - **comments** `array[object]`: - **id** `string`: No description - **message** `string`: No description - **createdTime** `string` (date-time): No description - **from** `object`: - **id** `string`: No description - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **isOwner** `boolean`: No description - **verifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X comments. - one of: blue, government, business, none - **likeCount** `integer`: No description - **replyCount** `integer`: No description - **platform** `string`: The platform this comment is from - **url** `string`: Direct link to the comment on the platform (if available) - **replies** `array[object]`: Type: `object` - **canReply** `boolean`: No description - **canDelete** `boolean`: No description - **canHide** `boolean`: Whether this comment can be hidden (Facebook - **canLike** `boolean`: Whether this comment can be liked (Facebook - **isHidden** `boolean`: Whether the comment is currently hidden - **isLiked** `boolean`: Whether the current user has liked this comment - **likeUri** `string`: Bluesky like URI for unliking - **cid** `string`: Bluesky content identifier - **parentId** `string`: Parent comment ID for nested replies - **rootUri** `string`: Bluesky root post URI - **rootCid** `string`: Bluesky root post CID - **post** `object`: (Reddit only) Metadata for the target post, returned alongside the comments in Reddit's single round-trip. Lets integrators render a preview of the post the user is commenting on without an additional request. Absent for non-Reddit platforms and when the upstream response is missing the post listing (deleted post, malformed response). - **id** `string`: Reddit post base36 id (e.g. "1tjtj26") - **fullname** `string`: Fullname with type prefix (e.g. "t3_1tjtj26") - **title** `string`: No description - **selftext** `string`: Body text for self-posts (empty for link posts) - **author** `string`: Reddit username - **subreddit** `string`: Subreddit name - **permalink** `string`: Absolute URL to the post on reddit.com - **url** `string`: For link posts - **score** `integer`: Net upvotes (upvotes minus downvotes) - **numComments** `integer`: No description - **createdUtc** `integer`: Unix timestamp in seconds - **over18** `boolean`: No description - **stickied** `boolean`: No description - **flairText** `string`: Link flair text if any - **isGallery** `boolean`: True if the post is a Reddit gallery (multiple images) - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description - **meta** `object`: - **platform** `string`: No description - **postId** `string`: No description - **accountId** `string`: No description - **subreddit** `string`: (Reddit only) Subreddit name - **lastUpdated** `string` (date-time): No description - **adComments** `object`: (Facebook/Instagram only) Present when this post has no organic comments but is a boosted post — the engagement lives on the ad. Use the ad-comments endpoint instead. - **adId** `string`: Internal Zernio ad ID - **adCommentsUrl** `string`: Path to fetch the ad's comments (GET /v1/ads/{adId}/comments) #### 400: Invalid request, or the postId belongs to a Meta ad creative / ad ID rather than an organic post (code USE_AD_COMMENTS_ENDPOINT — response includes `adId` and `adCommentsUrl`). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/comments/{postId} **Reply to comment** Post a reply to a post or specific comment. Requires accountId in request body. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. ### Request Body - **accountId** (required) `string`: No description - **message** (required) `string`: No description - **commentId** `string`: Reply to specific comment (optional) - **parentCid** `string`: (Bluesky only) Parent content identifier - **rootUri** `string`: (Bluesky only) Root post URI - **rootCid** `string`: (Bluesky only) Root post CID ### Responses #### 200: Reply posted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **commentId** `string`: No description - **isReply** `boolean`: No description - **cid** `string`: Bluesky CID #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId} **Delete comment** Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. Requires accountId and commentId query parameters. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **commentId** (required) in query: No description ### Responses #### 200: Comment deleted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **message** `string`: No description #### 400: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Hide comment API Reference 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. ## POST /v1/inbox/comments/{postId}/{commentId}/hide **Hide comment** 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. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description ### Request Body - **accountId** (required) `string`: The social account ID ### Responses #### 200: Comment hidden **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **hidden** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support hiding comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId}/{commentId}/hide **Unhide comment** Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Comment unhidden **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **hidden** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support unhiding comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Like comment API Reference 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. ## POST /v1/inbox/comments/{postId}/{commentId}/like **Like comment** 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. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description ### Request Body - **accountId** (required) `string`: The social account ID - **cid** `string`: (Bluesky only) Content identifier for the comment ### Responses #### 200: Comment liked **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **liked** `boolean`: No description - **likeUri** `string`: (Bluesky only) URI to use for unliking - **platform** `string`: No description #### 400: Platform does not support liking comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId}/{commentId}/like **Unlike comment** Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. For Bluesky, the likeUri query parameter is required. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description - **accountId** (required) in query: No description - **likeUri** (optional) in query: (Bluesky only) The like URI returned when liking ### Responses #### 200: Comment unliked **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **liked** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support unliking comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # List commented posts API Reference Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts. For users with the Ads add-on (Metronome plans always qualify), the user's Meta ads (boosted/dark posts) are included too. There's one row per (ad, placement-with-comments): an ad that runs on both Facebook feed and Instagram feed produces up to two rows (the Page dark post and the IG media have separate comment threads), each flagged `isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use `?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns *organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread from GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with the Marketing API token (Facebook side) or the connected Instagram account's token (Instagram side); a row whose count can't be read is omitted. ## GET /v1/inbox/comments **List commented posts** Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts. For users with the Ads add-on (Metronome plans always qualify), the user's Meta ads (boosted/dark posts) are included too. There's one row per (ad, placement-with-comments): an ad that runs on both Facebook feed and Instagram feed produces up to two rows (the Page dark post and the IG media have separate comment threads), each flagged `isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use `?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns *organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread from GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with the Marketing API token (Facebook side) or the connected Instagram account's token (Instagram side); a row whose count can't be read is omitted. ### Parameters - **profileId** (optional) in query: Filter by profile ID - **platform** (optional) in query: Filter by platform. `metaads` is a synthetic value meaning the user's ads (boosted/dark posts) only; `facebook`/`instagram` return organic posts only. - **minComments** (optional) in query: Minimum comment count - **since** (optional) in query: Posts created after this date - **sortBy** (optional) in query: Sort field - **sortOrder** (optional) in query: Sort order - **limit** (optional) in query: No description - **cursor** (optional) in query: No description - **accountId** (optional) in query: Filter by specific social account ID ### Responses #### 200: Aggregated posts with comments **Response Body:** - **data** `array[object]`: - **id** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **content** `string`: No description - **picture** `string`: No description - **permalink** `string`: No description - **createdTime** `string` (date-time): No description - **commentCount** `integer`: No description - **likeCount** `integer`: No description - **cid** `string`: Bluesky content identifier - **subreddit** `string`: Reddit subreddit name - **isAd** `boolean`: True when this row is an ad (boosted/dark post). `platform` is then the placement (facebook = the Page dark post / instagram = the IG media), `id` is `{adId}:{placement}`, and the thread is at GET /v1/ads/{adId}/comments?placement={placement}. - **adId** `string`: Internal Zernio ad id — only on ad rows. - **placement** `string`: Which side of the ad this row's comments are on — only on ad rows. - one of: facebook, instagram - **pagination** `object`: - **hasMore** `boolean`: No description - **nextCursor** `string`: No description - **meta** `object`: - **accountsQueried** `integer`: No description - **accountsFailed** `integer`: No description - **failedAccounts** `array[object]`: - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **error** `string`: No description - **code** `string`: Error code if available - **retryAfter** `integer`: Seconds to wait before retry (rate limits) - **lastUpdated** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Reply to comment API Reference Post a reply to a post or specific comment. Requires accountId in request body. ## GET /v1/inbox/comments/{postId} **Get post comments** Fetch comments for a specific post. Requires accountId query parameter. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **subreddit** (optional) in query: (Reddit only) Subreddit name - **limit** (optional) in query: Maximum number of comments to return - **cursor** (optional) in query: Pagination cursor - **commentId** (optional) in query: (Reddit only) Get replies to a specific comment ### Responses #### 200: Comments for the post **Response Body:** - **status** `string`: No description - **comments** `array[object]`: - **id** `string`: No description - **message** `string`: No description - **createdTime** `string` (date-time): No description - **from** `object`: - **id** `string`: No description - **name** `string`: No description - **username** `string`: No description - **picture** `string`: No description - **isOwner** `boolean`: No description - **verifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X comments. - one of: blue, government, business, none - **likeCount** `integer`: No description - **replyCount** `integer`: No description - **platform** `string`: The platform this comment is from - **url** `string`: Direct link to the comment on the platform (if available) - **replies** `array[object]`: Type: `object` - **canReply** `boolean`: No description - **canDelete** `boolean`: No description - **canHide** `boolean`: Whether this comment can be hidden (Facebook - **canLike** `boolean`: Whether this comment can be liked (Facebook - **isHidden** `boolean`: Whether the comment is currently hidden - **isLiked** `boolean`: Whether the current user has liked this comment - **likeUri** `string`: Bluesky like URI for unliking - **cid** `string`: Bluesky content identifier - **parentId** `string`: Parent comment ID for nested replies - **rootUri** `string`: Bluesky root post URI - **rootCid** `string`: Bluesky root post CID - **post** `object`: (Reddit only) Metadata for the target post, returned alongside the comments in Reddit's single round-trip. Lets integrators render a preview of the post the user is commenting on without an additional request. Absent for non-Reddit platforms and when the upstream response is missing the post listing (deleted post, malformed response). - **id** `string`: Reddit post base36 id (e.g. "1tjtj26") - **fullname** `string`: Fullname with type prefix (e.g. "t3_1tjtj26") - **title** `string`: No description - **selftext** `string`: Body text for self-posts (empty for link posts) - **author** `string`: Reddit username - **subreddit** `string`: Subreddit name - **permalink** `string`: Absolute URL to the post on reddit.com - **url** `string`: For link posts - **score** `integer`: Net upvotes (upvotes minus downvotes) - **numComments** `integer`: No description - **createdUtc** `integer`: Unix timestamp in seconds - **over18** `boolean`: No description - **stickied** `boolean`: No description - **flairText** `string`: Link flair text if any - **isGallery** `boolean`: True if the post is a Reddit gallery (multiple images) - **pagination** `object`: - **hasMore** `boolean`: No description - **cursor** `string`: No description - **meta** `object`: - **platform** `string`: No description - **postId** `string`: No description - **accountId** `string`: No description - **subreddit** `string`: (Reddit only) Subreddit name - **lastUpdated** `string` (date-time): No description - **adComments** `object`: (Facebook/Instagram only) Present when this post has no organic comments but is a boosted post — the engagement lives on the ad. Use the ad-comments endpoint instead. - **adId** `string`: Internal Zernio ad ID - **adCommentsUrl** `string`: Path to fetch the ad's comments (GET /v1/ads/{adId}/comments) #### 400: Invalid request, or the postId belongs to a Meta ad creative / ad ID rather than an organic post (code USE_AD_COMMENTS_ENDPOINT — response includes `adId` and `adCommentsUrl`). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/comments/{postId} **Reply to comment** Post a reply to a post or specific comment. Requires accountId in request body. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. ### Request Body - **accountId** (required) `string`: No description - **message** (required) `string`: No description - **commentId** `string`: Reply to specific comment (optional) - **parentCid** `string`: (Bluesky only) Parent content identifier - **rootUri** `string`: (Bluesky only) Root post URI - **rootCid** `string`: (Bluesky only) Root post CID ### Responses #### 200: Reply posted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **commentId** `string`: No description - **isReply** `boolean`: No description - **cid** `string`: Bluesky CID #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId} **Delete comment** Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. Requires accountId and commentId query parameters. ### Parameters - **postId** (required) in path: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. - **accountId** (required) in query: No description - **commentId** (required) in query: No description ### Responses #### 200: Comment deleted **Response Body:** - **success** `boolean`: No description - **data** `object`: - **message** `string`: No description #### 400: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Send private reply API Reference Send a private message to the author of a comment. Supported on Instagram and Facebook only. One reply per comment, must be sent within 7 days. Optionally attach interactive elements: `quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url buttons rendered in the same bubble via Meta's button_template). Buttons are recommended for cold reach since chips do not render in the Instagram Message Requests folder. `quickReplies` and `buttons` are mutually exclusive. ## POST /v1/inbox/comments/{postId}/{commentId}/private-reply **Send private reply** Send a private message to the author of a comment. Supported on Instagram and Facebook only. One reply per comment, must be sent within 7 days. Optionally attach interactive elements: `quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url buttons rendered in the same bubble via Meta's button_template). Buttons are recommended for cold reach since chips do not render in the Instagram Message Requests folder. `quickReplies` and `buttons` are mutually exclusive. ### Parameters - **postId** (required) in path: The media/post ID (Instagram media ID or Facebook post ID) - **commentId** (required) in path: The comment ID to send a private reply to ### Request Body - **accountId** (required) `string`: The social account ID (Instagram or Facebook) - **message** (required) `string`: The message text to send as a private DM - **quickReplies** `array`: Optional quick-reply chips appended to the message. Visible only in the Instagram and Messenger apps (not on web). Maximum 13 entries. Mutually exclusive with `buttons`. Note: chips do NOT render in the Instagram Message Requests folder where DMs from non-followers land — use `buttons` instead for cold reach. - **buttons** `array`: Optional 1-3 inline buttons rendered as part of the same message bubble via Meta's button_template. Visible in the Instagram Message Requests folder (unlike quick replies). Mutually exclusive with `quickReplies`. ### Responses #### 200: Private reply sent successfully **Response Body:** - **status** `string`: No description (example: "success") - **messageId** `string`: The ID of the sent message - **commentId** `string`: The comment ID that was replied to - **platform** `string`: No description - one of: instagram, facebook (example: "instagram") #### 400: Bad request **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: PLATFORM_LIMITATION #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account not found --- --- # Unhide comment API Reference Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter. ## POST /v1/inbox/comments/{postId}/{commentId}/hide **Hide comment** 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. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description ### Request Body - **accountId** (required) `string`: The social account ID ### Responses #### 200: Comment hidden **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **hidden** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support hiding comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId}/{commentId}/hide **Unhide comment** Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Comment unhidden **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **hidden** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support unhiding comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Unlike comment API Reference Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. For Bluesky, the likeUri query parameter is required. ## POST /v1/inbox/comments/{postId}/{commentId}/like **Like comment** 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. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description ### Request Body - **accountId** (required) `string`: The social account ID - **cid** `string`: (Bluesky only) Content identifier for the comment ### Responses #### 200: Comment liked **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **liked** `boolean`: No description - **likeUri** `string`: (Bluesky only) URI to use for unliking - **platform** `string`: No description #### 400: Platform does not support liking comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/comments/{postId}/{commentId}/like **Unlike comment** Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. For Bluesky, the likeUri query parameter is required. ### Parameters - **postId** (required) in path: No description - **commentId** (required) in path: No description - **accountId** (required) in query: No description - **likeUri** (optional) in query: (Bluesky only) The like URI returned when liking ### Responses #### 200: Comment unliked **Response Body:** - **status** `string`: No description - **commentId** `string`: No description - **liked** `boolean`: No description - **platform** `string`: No description #### 400: Platform does not support unliking comments #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Bulk create contacts API Reference Import up to 1000 contacts at a time. Skips duplicates. ## POST /v1/contacts/bulk **Bulk create contacts** Import up to 1000 contacts at a time. Skips duplicates. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: No description - **platform** (required) `string`: No description - **contacts** (required) `array`: No description ### Responses #### 200: Bulk import results **Response Body:** - **success** `boolean`: No description - **created** `integer`: No description - **skipped** `integer`: No description - **errors** `array[object]`: Type: `object` - **total** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Create contact API Reference Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier. ## GET /v1/contacts **List contacts** List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **search** (optional) in query: No description - **tag** (optional) in query: No description - **platform** (optional) in query: No description - **isSubscribed** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Contacts list with pagination and filter metadata **Response Body:** - **success** `boolean`: No description - **contacts** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **lastMessageSentAt** `string` (date-time): No description - **lastMessageReceivedAt** `string` (date-time): No description - **messagesSentCount** `integer`: No description - **messagesReceivedCount** `integer`: No description - **customFields** `object`: No description - **notes** `string`: No description - **createdAt** `string` (date-time): No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **filters** `object`: - **tags** `array[string]`: - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/contacts **Create contact** Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier. ### Request Body - **profileId** (required) `string`: No description - **name** (required) `string`: No description - **email** `string`: No description - **company** `string`: No description - **tags** `array`: No description - **isSubscribed** `boolean`: No description - **notes** `string`: No description - **accountId** `string`: Optional. Creates a channel if provided with platform + platformIdentifier - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description ### Responses #### 200: Contact created **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **customFields** `object`: No description - **notes** `string`: No description - **createdAt** `string` (date-time): No description - **channel** `object`: Created when accountId, platform, and platformIdentifier are provided - **id** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **warning** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Duplicate contact --- --- # Delete contact API Reference Permanently deletes a contact and all associated channels. ## GET /v1/contacts/{contactId} **Get contact** Returns a contact with all associated messaging channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact with channels **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **messagesSentCount** `integer`: Messages sent to the contact, derived live from message history across all linked conversations. - **messagesReceivedCount** `integer`: Messages received from the contact, derived live from message history across all linked conversations. - **lastMessageSentAt** `string` (date-time): Timestamp of the most recent outgoing message, or null if none. - **lastMessageReceivedAt** `string` (date-time): Timestamp of the most recent incoming message, or null if none. - **customFields** `object`: No description - **notes** `string`: No description - **conversationIds** `array[string]`: - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **channels** `array[object]`: - **id** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **isSubscribed** `boolean`: No description - **conversationId** `string`: No description - **lastActiveAt** `string` (date-time): Most recent message (either direction) in this channel's conversation, or null if none. - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/contacts/{contactId} **Update contact** Update one or more fields on a contact. Only provided fields are changed. ### Parameters - **contactId** (required) in path: No description ### Request Body - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array`: No description - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description ### Responses #### 200: Contact updated **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/contacts/{contactId} **Delete contact** Permanently deletes a contact and all associated channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List channels for a contact API Reference Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp). ## GET /v1/contacts/{contactId}/channels **List channels for a contact** Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp). ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: List of contact channels **Response Body:** - **success** `boolean`: No description - **channels** `array[object]`: - **id** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **isSubscribed** `boolean`: No description - **conversationId** `string`: No description - **metadata** `object`: No description - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Get contact API Reference Returns a contact with all associated messaging channels. ## GET /v1/contacts/{contactId} **Get contact** Returns a contact with all associated messaging channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact with channels **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **messagesSentCount** `integer`: Messages sent to the contact, derived live from message history across all linked conversations. - **messagesReceivedCount** `integer`: Messages received from the contact, derived live from message history across all linked conversations. - **lastMessageSentAt** `string` (date-time): Timestamp of the most recent outgoing message, or null if none. - **lastMessageReceivedAt** `string` (date-time): Timestamp of the most recent incoming message, or null if none. - **customFields** `object`: No description - **notes** `string`: No description - **conversationIds** `array[string]`: - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **channels** `array[object]`: - **id** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **isSubscribed** `boolean`: No description - **conversationId** `string`: No description - **lastActiveAt** `string` (date-time): Most recent message (either direction) in this channel's conversation, or null if none. - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/contacts/{contactId} **Update contact** Update one or more fields on a contact. Only provided fields are changed. ### Parameters - **contactId** (required) in path: No description ### Request Body - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array`: No description - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description ### Responses #### 200: Contact updated **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/contacts/{contactId} **Delete contact** Permanently deletes a contact and all associated channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List contacts API Reference List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search. ## GET /v1/contacts **List contacts** List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **search** (optional) in query: No description - **tag** (optional) in query: No description - **platform** (optional) in query: No description - **isSubscribed** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Contacts list with pagination and filter metadata **Response Body:** - **success** `boolean`: No description - **contacts** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **lastMessageSentAt** `string` (date-time): No description - **lastMessageReceivedAt** `string` (date-time): No description - **messagesSentCount** `integer`: No description - **messagesReceivedCount** `integer`: No description - **customFields** `object`: No description - **notes** `string`: No description - **createdAt** `string` (date-time): No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **filters** `object`: - **tags** `array[string]`: - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/contacts **Create contact** Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier. ### Request Body - **profileId** (required) `string`: No description - **name** (required) `string`: No description - **email** `string`: No description - **company** `string`: No description - **tags** `array`: No description - **isSubscribed** `boolean`: No description - **notes** `string`: No description - **accountId** `string`: Optional. Creates a channel if provided with platform + platformIdentifier - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description ### Responses #### 200: Contact created **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **customFields** `object`: No description - **notes** `string`: No description - **createdAt** `string` (date-time): No description - **channel** `object`: Created when accountId, platform, and platformIdentifier are provided - **id** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **warning** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Duplicate contact --- --- # Update contact API Reference Update one or more fields on a contact. Only provided fields are changed. ## GET /v1/contacts/{contactId} **Get contact** Returns a contact with all associated messaging channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact with channels **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **messagesSentCount** `integer`: Messages sent to the contact, derived live from message history across all linked conversations. - **messagesReceivedCount** `integer`: Messages received from the contact, derived live from message history across all linked conversations. - **lastMessageSentAt** `string` (date-time): Timestamp of the most recent outgoing message, or null if none. - **lastMessageReceivedAt** `string` (date-time): Timestamp of the most recent incoming message, or null if none. - **customFields** `object`: No description - **notes** `string`: No description - **conversationIds** `array[string]`: - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description - **channels** `array[object]`: - **id** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **platformIdentifier** `string`: No description - **displayIdentifier** `string`: No description - **isSubscribed** `boolean`: No description - **conversationId** `string`: No description - **lastActiveAt** `string` (date-time): Most recent message (either direction) in this channel's conversation, or null if none. - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/contacts/{contactId} **Update contact** Update one or more fields on a contact. Only provided fields are changed. ### Parameters - **contactId** (required) in path: No description ### Request Body - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array`: No description - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description ### Responses #### 200: Contact updated **Response Body:** - **success** `boolean`: No description - **contact** `object`: - **id** `string`: No description - **name** `string`: No description - **email** `string`: No description - **company** `string`: No description - **avatarUrl** `string`: No description - **tags** `array[string]`: - **isSubscribed** `boolean`: No description - **isBlocked** `boolean`: No description - **notes** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/contacts/{contactId} **Delete contact** Permanently deletes a contact and all associated channels. ### Parameters - **contactId** (required) in path: No description ### Responses #### 200: Contact deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Check Telegram status API Reference 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). ## GET /v1/connect/telegram **Generate Telegram code** 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 - **profileId** (required) in query: The profile ID to connect the Telegram account to ### Responses #### 200: Access code generated **Response Body:** - **code** `string`: The access code to send to the Telegram bot (example: "ZRN-ABC123") - **expiresAt** `string` (date-time): When the code expires - **expiresIn** `integer`: Seconds until expiration (example: 900) - **botUsername** `string`: The Telegram bot username to message (example: "LateScheduleBot") - **instructions** `array[string]`: Step-by-step connection instructions #### 400: Profile ID required or invalid format #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## POST /v1/connect/telegram **Connect Telegram directly** 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. ### Request Body - **chatId** (required) `string`: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). - **profileId** (required) `string`: The profile ID to connect the account to ### Responses #### 200: Telegram channel connected successfully **Response Body:** - **message** `string`: No description - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - one of: telegram - **username** `string`: No description - **displayName** `string`: No description - **isActive** `boolean`: No description - **chatType** `string`: No description - one of: channel, group, supergroup, private #### 400: Chat ID required #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## PATCH /v1/connect/telegram **Check Telegram status** 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 - **code** (required) in query: The access code to check status for ### Responses #### 200: Connection status **Response Body:** *One of the following:* - **status** `string`: No description - one of: pending - **expiresAt** `string` (date-time): No description - **expiresIn** `integer`: Seconds until expiration - **status** `string`: No description - one of: connected - **chatId** `string`: No description - **chatTitle** `string`: No description - **chatType** `string`: No description - one of: channel, group, supergroup - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **status** `string`: No description - one of: expired - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Code not found #### 500: Internal error --- --- # Complete WhatsApp phone number selection API Reference Bind a specific WhatsApp phone number to the Zernio profile after the user picks one from `listWhatsAppPhoneNumbers`. Exchanges the short-lived OAuth token for a long-lived token, subscribes the WABA to webhooks, and creates the SocialAccount. ## GET /v1/connect/whatsapp/select-phone-number **List WhatsApp phone numbers for selection** Fetch the WhatsApp phone numbers available across the user's WhatsApp Business Accounts (WABAs) after a headless OAuth flow. WhatsApp OAuth grants access at the WABA level. When a connected WABA has 2 or more phone numbers, you must call this endpoint to list them and then `POST /v1/connect/whatsapp/select-phone-number` to bind one to the Zernio profile. Single-phone WABAs auto-complete during the OAuth callback and never reach this endpoint. Use the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`). Alternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow. ### Parameters - **profileId** (required) in query: The Zernio profile ID from the headless redirect - **tempToken** (required) in query: The temporary access token from the headless redirect - **X-Connect-Token** (optional) in header: Alternative auth for API users' end customers (used when the bearer token is scoped to a different user) ### Responses #### 200: Phone numbers fetched successfully **Response Body:** - **phoneNumbers** `array[object]`: - **id** `string`: Phone Number ID (Meta) - **display_phone_number** `string`: E.164-formatted display number - **verified_name** `string`: Meta-verified business name - **quality_rating** `string`: GREEN, YELLOW, RED, or UNKNOWN - **name_status** `string`: APPROVED, PENDING_REVIEW, DECLINED, or NONE - **messaging_limit_tier** `string`: TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED - **wabaId** `string`: WhatsApp Business Account ID (Zernio enrichment) - **wabaName** `string`: WABA display name (Zernio enrichment) #### 400: Missing profileId or tempToken **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to fetch phone numbers (Meta API error, expired token, or insufficient permissions) **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/connect/whatsapp/select-phone-number **Complete WhatsApp phone number selection** Bind a specific WhatsApp phone number to the Zernio profile after the user picks one from `listWhatsAppPhoneNumbers`. Exchanges the short-lived OAuth token for a long-lived token, subscribes the WABA to webhooks, and creates the SocialAccount. ### Parameters - **X-Connect-Token** (optional) in header: Alternative auth for API users' end customers ### Request Body - **profileId** (required) `string`: The Zernio profile ID - **phoneNumberId** (required) `string`: The selected phone number ID (from listWhatsAppPhoneNumbers) - **wabaId** (required) `string`: The WABA ID containing the selected phone - **tempToken** (required) `string`: The temporary access token from the headless redirect - **userProfile** `object`: Optional user profile data (passthrough) - **redirect_url** `string`: Optional URL to receive the post-connection redirect target ### Responses #### 200: Phone number connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Present only if redirect_url was provided in the request - **account** `object`: - **accountId** `string`: No description - **platform** `string`: No description - one of: whatsapp - **username** `string`: Display phone number - **displayName** `string`: Meta-verified business name - **isActive** `boolean`: No description - **selectedPhoneNumber** `string`: No description #### 400: Missing required fields (profileId, phoneNumberId, wabaId, or tempToken) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Profile limit exceeded for the user's plan (PROFILE_LIMIT_EXCEEDED) #### 404: Selected phone number not found in the specified WABA **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 500: Failed to bind phone number **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Configure TikTok Ads Brand Identity API Reference 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. ## PATCH /v1/connect/tiktok-ads **Configure TikTok Ads Brand Identity** 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. ### Request Body - **accountId** (required) `string`: SocialAccount ID of the `tiktokads` account. - **displayName** (required) `string`: Brand name shown above the ad on TikTok. - **imageUrl** (required) `string`: 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: Brand identity configured (or updated) **Response Body:** - **success** `boolean`: No description (example: true) - **identityId** `string`: The TikTok-assigned identity_id - **displayName** `string`: No description #### 400: Missing fields, invalid lengths, account is in linked mode, or no advertiser found on the account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: TikTok Ads account not found #### 500: Failed to create the TikTok identity (TikTok API error) --- --- # Connect ads for a platform API Reference 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. ## GET /v1/connect/{platform}/ads **Connect ads for a platform** 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 - **platform** (required) in path: Platform to connect ads for. Only platforms with ads support are accepted. - **profileId** (required) in query: Your Zernio profile ID - **accountId** (optional) in query: 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. - **redirect_url** (optional) in query: Custom redirect URL after OAuth completes (same-token platforms only) - **headless** (optional) in query: Enable headless mode (same-token platforms only) - **adAccountId** (optional) in query: Scope ad sync to a single platform ad account. Without this param, sync covers every ad account the connected token can see. Supported on `facebook`/`instagram` (Meta, `act_`), `linkedin` (bare numeric sponsored-account id), `googleads` (bare customer id digits) and `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers at OAuth and `pinterest` has no ads discovery, so both ignore it. Meta ids are additionally validated against the connected token; unreachable IDs return 400. Setting a scope also removes already synced ads from de-scoped ad accounts. For multiple accounts use `adAccountIds` instead. - **adAccountIds** (optional) in query: Scope ad sync to multiple platform ad accounts (same platform support and id shapes as `adAccountId`). Repeat the param (`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate (`?adAccountIds=act_1,act_2`). Persisted server-side; latest call wins, and de-scoped ad accounts have their synced ads removed. Omitting both `adAccountId` and `adAccountIds` keeps any previously persisted scope unchanged. ### Responses #### 200: Either an OAuth URL to redirect to, or confirmation that ads are already connected **Response Body:** *One of the following:* - **alreadyConnected** `boolean`: No description (example: true) - **accountId** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **scopedAdAccountIds** `array[string]`: Echo of the persisted ad-account scope when the caller passed `adAccountId` / `adAccountIds`. Omitted when no scope is set. - **authUrl** `string` (uri): No description - **state** `string`: No description #### 400: Platform doesn't support ads, or missing accountId for X Ads #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or no access to profile #### 404: Profile or posting account not found --- --- # Connect Bluesky account API Reference 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. ## POST /v1/connect/bluesky/credentials **Connect Bluesky account** 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. ### Request Body - **identifier** (required) `string`: Your Bluesky handle (e.g. user.bsky.social) or email address - **appPassword** (required) `string`: App password generated from Bluesky Settings > App Passwords - **state** (required) `string`: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles. - **redirectUri** `string`: Optional URL to redirect to after successful connection ### Responses #### 200: Bluesky connected successfully **Response Body:** - **message** `string`: No description - **account**: `SocialAccount` - See schema definition #### 400: Invalid request - missing fields or invalid state format #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal error --- # Related Schema Definitions ## SocialAccount ### Properties - **_id** (required) `string`: No description - **platform** (required) `string`: No description - one of: tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads - **profileId** (required): No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: URL to the account's profile picture on the platform. May be null if the platform does not provide one. - **profileUrl** `string`: Full profile URL for the connected account on its platform. - **isActive** (required) `boolean`: No description - **followersCount** `number`: Follower count (only included if user has analytics add-on) - **followersLastUpdated** `string`: Last time follower count was updated (only included if user has analytics add-on) - **parentAccountId** `string`: 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** `boolean`: 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** `object`: 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 --- # Connect WhatsApp via credentials API Reference 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) ## POST /v1/connect/whatsapp/credentials **Connect WhatsApp via credentials** 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) ### Request Body - **profileId** (required) `string`: Your Zernio profile ID - **accessToken** (required) `string`: Permanent System User access token from Meta Business Suite - **wabaId** (required) `string`: WhatsApp Business Account ID from Meta - **phoneNumberId** (required) `string`: Phone Number ID from Meta WhatsApp Manager ### Responses #### 200: WhatsApp connected successfully **Response Body:** - **message** `string`: No description - **account** `object`: - **accountId** `string`: No description - **platform** `string`: No description - one of: whatsapp - **username** `string`: Display phone number - **displayName** `string`: Meta-verified business name - **isActive** `boolean`: No description - **selectedPhoneNumber** `string`: The connected phone number #### 400: 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: Invalid or expired access token #### 403: Profile limit exceeded for this plan --- --- # Get OAuth connect URL API Reference 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. ## GET /v1/connect/{platform} **Get OAuth connect URL** 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 - **platform** (required) in path: Social media platform to connect - **profileId** (required) in query: Your Zernio profile ID (get from /v1/profiles) - **redirect_url** (optional) in query: 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. - **headless** (optional) in query: 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. ### Responses #### 200: OAuth authorization URL to redirect user to **Response Body:** - **authUrl** `string` (uri): URL to redirect your user to for OAuth authorization - **state** `string`: State parameter for security (handled automatically) #### 400: Missing/invalid parameters (e.g., invalid profileId format) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Payment method or enterprise contract required. The authenticated account hit a billing gate before the connection could proceed. Three reasons: - `free_tier_exceeded`: the team has connected more accounts than the free tier allows. Add a payment method on the dashboard to continue (the user will be billed per additional connected account). - `twitter_passthrough`: connecting an X (Twitter) account requires a card on file from day one because X API calls incur real per-call pass-through costs. Applies to the 1st X account, not just the 3rd+. - `enterprise_required`: the team has reached the public pricing ceiling (2,000 connected accounts). Beyond that requires a custom enterprise contract negotiated directly. `dashboard_url` deep-links to the enterprise contact page rather than the billing tab. The end-user already has a card on file; this gate is about contract terms, not card collection. SDK consumers should switch on `reason` to render the right prompt. For `free_tier_exceeded` and `twitter_passthrough`, redirect the end-user to `dashboard_url` to add a payment method via Zernio's hosted Stripe Setup Checkout. For `enterprise_required`, redirect to `dashboard_url` (the enterprise contact form) so they can talk to sales. **Response Body:** - **error** (required) `string`: Human-readable error message suitable for end-user display. (example: "X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.") - **code** (required) `string`: Machine-readable error code. Stable across versions. - one of: PAYMENT_REQUIRED - **reason** (required) `string`: Discriminator for which gate fired. - one of: free_tier_exceeded, twitter_passthrough, enterprise_required - **documentation_url** `string` (uri): Link to the relevant documentation page. (example: "https://docs.zernio.com/billing/payment-method-required") - **dashboard_url** `string` (uri): Deep-link to send the end-user to. For `free_tier_exceeded` and `twitter_passthrough` this is the Zernio billing tab. For `enterprise_required` this is the Zernio enterprise contact page. (example: "https://zernio.com/dashboard?tab=billing") - **details** `object`: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`. - **free_tier_account_limit** `integer`: How many accounts the free tier allows. Only set when reason=free_tier_exceeded. (example: 2) - **current_account_count** `integer`: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required. (example: 5) - **has_payment_method** `boolean`: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough. - **public_account_limit** `integer`: Public pricing ceiling (the published cap beyond which an enterprise contract is required). Only set when reason=enterprise_required. (example: 2000) - **effective_account_limit** `integer`: The cap actually applied to this team. Equals `public_account_limit` for organic teams; for teams with a per-customer override (grandfathered legacy customers, signed enterprise contracts) this can be higher. Only set when reason=enterprise_required. (example: 2000) #### 403: No access to profile, or BYOK required for AppSumo Twitter #### 404: Profile not found --- ## POST /v1/connect/{platform} **Complete OAuth callback** Exchange the OAuth authorization code for tokens and connect the account to the specified profile. ### Parameters - **platform** (required) in path: No description ### Request Body - **code** (required) `string`: No description - **state** (required) `string`: No description - **profileId** (required) `string`: No description ### Responses #### 200: Account connected #### 400: Invalid params #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: BYOK required for AppSumo Twitter #### 500: Failed to connect account --- --- # List Facebook pages API Reference Returns all Facebook pages the connected account has access to, including the currently selected page. ## GET /v1/accounts/{accountId}/facebook-page **List Facebook pages** Returns all Facebook pages the connected account has access to, including the currently selected page. ### Parameters - **accountId** (required) in path: No description - **refresh** (optional) in query: When true, bypasses the page cache and fetches fresh pages from Meta. Rate-limited server-side to 1 refresh per 60s. Pages no longer accessible to the connected account will be removed from the list on refresh. ### Responses #### 200: Pages list **Response Body:** - **pages** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **username** `string`: No description - **category** `string`: No description - **fan_count** `integer`: No description - **selectedPageId** `string`: No description - **cached** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/facebook-page **Update Facebook page** Switch which Facebook Page is active for a connected account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **selectedPageId** (required) `string`: No description ### Responses #### 200: Page updated **Response Body:** - **message** `string`: No description - **selectedPage** `object`: - **id** `string`: No description - **name** `string`: No description #### 400: Page not in available pages #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # List GBP locations API Reference Returns Google Business Profile locations the connected account can access, plus the currently selected location. The list is bounded (see hasMore); for accounts that own many locations, use the search or filter query params to find a specific one instead of loading them all. ## GET /v1/accounts/{accountId}/gmb-locations **List GBP locations** Returns Google Business Profile locations the connected account can access, plus the currently selected location. The list is bounded (see hasMore); for accounts that own many locations, use the search or filter query params to find a specific one instead of loading them all. ### Parameters - **accountId** (required) in path: No description - **search** (optional) in query: Free-text search on the business name, applied server-side by Google. Use for accounts with many locations. - **filter** (optional) in query: Raw Google Business Information API filter expression (advanced; takes precedence over search), e.g. storeCode="LH279411". ### Responses #### 200: Locations list **Response Body:** - **locations** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **accountId** `string`: No description - **accountName** `string`: No description - **address** `string`: No description - **category** `string`: No description - **websiteUrl** `string`: No description - **storeCode** `string`: No description - **hasMore** `boolean`: True when more locations exist than were returned (use search to narrow down). - **selectedLocationId** `string`: No description - **cached** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/gmb-locations **Update GBP location** Switch which GBP location is active for a connected account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **selectedLocationId** (required) `string`: No description - **googleAccountId** `string`: Optional but recommended. The Google Business Account resource name ("accounts/123") that owns the new location (from GET gmb-locations). When provided, the location is resolved directly instead of by enumerating the account, which is required for accounts with many locations. Named `googleAccountId` to disambiguate from the path `accountId` (the Zernio account). The legacy field name `accountId` is still accepted for backwards compatibility. ### Responses #### 200: Location updated **Response Body:** - **message** `string`: No description - **selectedLocation** `object`: - **id** `string`: No description - **name** `string`: No description #### 400: Location not in available locations #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # List LinkedIn orgs API Reference Returns LinkedIn organizations (company pages) the connected account has admin access to. ## GET /v1/accounts/{accountId}/linkedin-organizations **List LinkedIn orgs** Returns LinkedIn organizations (company pages) the connected account has admin access to. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Organizations list **Response Body:** - **organizations** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **vanityName** `string`: No description - **localizedName** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Get pending OAuth data API Reference Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL. **Scope**: This endpoint is used only for LinkedIn organizations and Snapchat profiles, where the selection list is too large to fit in URL params. WhatsApp, Facebook, Pinterest, Google Business and other platforms pass selection state directly via URL query params on the redirect (`profileId`, `tempToken`, `step`), no pending record is created, so this endpoint will return 404 for those flows. Use the platform-specific selection endpoint instead (e.g. `/v1/connect/whatsapp/select-phone-number`). Token is one-time use and expires after 10 minutes. No authentication required. ## GET /v1/connect/pending-data **Get pending OAuth data** Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL. **Scope**: This endpoint is used only for LinkedIn organizations and Snapchat profiles, where the selection list is too large to fit in URL params. WhatsApp, Facebook, Pinterest, Google Business and other platforms pass selection state directly via URL query params on the redirect (`profileId`, `tempToken`, `step`), no pending record is created, so this endpoint will return 404 for those flows. Use the platform-specific selection endpoint instead (e.g. `/v1/connect/whatsapp/select-phone-number`). Token is one-time use and expires after 10 minutes. No authentication required. ### Parameters - **token** (required) in query: The pending data token from the OAuth redirect URL (pendingDataToken parameter) ### Responses #### 200: OAuth data fetched successfully **Response Body:** - **platform** `string`: The platform (e.g., "linkedin") - **profileId** `string`: The Zernio profile ID - **tempToken** `string`: Temporary access token for the platform - **refreshToken** `string`: Refresh token (if available) - **expiresIn** `number`: Token expiry in seconds - **userProfile** `object`: User profile data (id, username, displayName, profilePicture) - **selectionType** `string`: Type of selection data - one of: organizations, pages, boards, locations, profiles - **organizations** `array[object]`: LinkedIn organizations (when selectionType is "organizations") - **id** `string`: No description - **urn** `string`: No description - **name** `string`: No description - **vanityName** `string`: No description #### 400: Missing token parameter **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Token not found or expired **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # List Pinterest boards API Reference Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post. ## GET /v1/accounts/{accountId}/pinterest-boards **List Pinterest boards** Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Boards list **Response Body:** - **boards** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **privacy** `string`: No description #### 400: Not a Pinterest account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/pinterest-boards **Set default Pinterest board** Sets the default board used when publishing pins for this account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **defaultBoardId** (required) `string`: No description - **defaultBoardName** `string`: No description ### Responses #### 200: Default board set **Response Body:** - **message** `string`: No description - **account**: `SocialAccount` - See schema definition #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- # Related Schema Definitions ## SocialAccount ### Properties - **_id** (required) `string`: No description - **platform** (required) `string`: No description - one of: tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads - **profileId** (required): No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: URL to the account's profile picture on the platform. May be null if the platform does not provide one. - **profileUrl** `string`: Full profile URL for the connected account on its platform. - **isActive** (required) `boolean`: No description - **followersCount** `number`: Follower count (only included if user has analytics add-on) - **followersLastUpdated** `string`: Last time follower count was updated (only included if user has analytics add-on) - **parentAccountId** `string`: 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** `boolean`: 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** `object`: 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 --- # List subreddit flairs API Reference Returns available post flairs for a subreddit. Some subreddits require a flair when posting. ## GET /v1/accounts/{accountId}/reddit-flairs **List subreddit flairs** Returns available post flairs for a subreddit. Some subreddits require a flair when posting. ### Parameters - **accountId** (required) in path: No description - **subreddit** (required) in query: Subreddit name (without "r/" prefix) to fetch flairs for ### Responses #### 200: Flairs list **Response Body:** - **flairs** `array[object]`: - **id** `string`: Flair ID to pass as flairId in platformSpecificData - **text** `string`: Flair display text - **textColor** `string`: Text color: 'dark' or 'light' - **backgroundColor** `string`: Background hex color (e.g. '#ff4500') #### 400: Not a Reddit account or missing subreddit parameter #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # List Reddit subreddits API Reference Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post. ## GET /v1/accounts/{accountId}/reddit-subreddits **List Reddit subreddits** Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Subreddits list **Response Body:** - **subreddits** `array[object]`: - **id** `string`: Reddit subreddit ID - **name** `string`: Subreddit name without r/ prefix - **title** `string`: Subreddit title - **url** `string`: Subreddit URL path - **over18** `boolean`: Whether the subreddit is NSFW - **defaultSubreddit** `string`: Currently set default subreddit for posting #### 400: Not a Reddit account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/reddit-subreddits **Set default subreddit** Sets the default subreddit used when publishing posts for this Reddit account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **defaultSubreddit** (required) `string`: No description ### Responses #### 200: Default subreddit set **Response Body:** - **success** `boolean`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Generate Telegram code API Reference 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. ## GET /v1/connect/telegram **Generate Telegram code** 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 - **profileId** (required) in query: The profile ID to connect the Telegram account to ### Responses #### 200: Access code generated **Response Body:** - **code** `string`: The access code to send to the Telegram bot (example: "ZRN-ABC123") - **expiresAt** `string` (date-time): When the code expires - **expiresIn** `integer`: Seconds until expiration (example: 900) - **botUsername** `string`: The Telegram bot username to message (example: "LateScheduleBot") - **instructions** `array[string]`: Step-by-step connection instructions #### 400: Profile ID required or invalid format #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## POST /v1/connect/telegram **Connect Telegram directly** 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. ### Request Body - **chatId** (required) `string`: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). - **profileId** (required) `string`: The profile ID to connect the account to ### Responses #### 200: Telegram channel connected successfully **Response Body:** - **message** `string`: No description - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - one of: telegram - **username** `string`: No description - **displayName** `string`: No description - **isActive** `boolean`: No description - **chatType** `string`: No description - one of: channel, group, supergroup, private #### 400: Chat ID required #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## PATCH /v1/connect/telegram **Check Telegram status** 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 - **code** (required) in query: The access code to check status for ### Responses #### 200: Connection status **Response Body:** *One of the following:* - **status** `string`: No description - one of: pending - **expiresAt** `string` (date-time): No description - **expiresIn** `integer`: Seconds until expiration - **status** `string`: No description - one of: connected - **chatId** `string`: No description - **chatTitle** `string`: No description - **chatType** `string`: No description - one of: channel, group, supergroup - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **status** `string`: No description - one of: expired - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Code not found #### 500: Internal error --- --- # List YouTube playlists API Reference 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. ## GET /v1/accounts/{accountId}/youtube-playlists **List YouTube playlists** 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 - **accountId** (required) in path: No description ### Responses #### 200: Playlists list **Response Body:** - **playlists** `array[object]`: - **id** `string`: No description - **title** `string`: No description - **description** `string`: No description - **privacy** `string`: No description - one of: public, private, unlisted - **itemCount** `integer`: No description - **thumbnailUrl** `string`: No description - **defaultPlaylistId** `string`: No description #### 400: Not a YouTube account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/youtube-playlists **Set default YouTube playlist** 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 - **accountId** (required) in path: No description ### Request Body - **defaultPlaylistId** (required) `string`: No description - **defaultPlaylistName** `string`: No description ### Responses #### 200: Default playlist set **Response Body:** - **success** `boolean`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Complete OAuth callback API Reference Exchange the OAuth authorization code for tokens and connect the account to the specified profile. ## GET /v1/connect/{platform} **Get OAuth connect URL** 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 - **platform** (required) in path: Social media platform to connect - **profileId** (required) in query: Your Zernio profile ID (get from /v1/profiles) - **redirect_url** (optional) in query: 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. - **headless** (optional) in query: 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. ### Responses #### 200: OAuth authorization URL to redirect user to **Response Body:** - **authUrl** `string` (uri): URL to redirect your user to for OAuth authorization - **state** `string`: State parameter for security (handled automatically) #### 400: Missing/invalid parameters (e.g., invalid profileId format) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Payment method or enterprise contract required. The authenticated account hit a billing gate before the connection could proceed. Three reasons: - `free_tier_exceeded`: the team has connected more accounts than the free tier allows. Add a payment method on the dashboard to continue (the user will be billed per additional connected account). - `twitter_passthrough`: connecting an X (Twitter) account requires a card on file from day one because X API calls incur real per-call pass-through costs. Applies to the 1st X account, not just the 3rd+. - `enterprise_required`: the team has reached the public pricing ceiling (2,000 connected accounts). Beyond that requires a custom enterprise contract negotiated directly. `dashboard_url` deep-links to the enterprise contact page rather than the billing tab. The end-user already has a card on file; this gate is about contract terms, not card collection. SDK consumers should switch on `reason` to render the right prompt. For `free_tier_exceeded` and `twitter_passthrough`, redirect the end-user to `dashboard_url` to add a payment method via Zernio's hosted Stripe Setup Checkout. For `enterprise_required`, redirect to `dashboard_url` (the enterprise contact form) so they can talk to sales. **Response Body:** - **error** (required) `string`: Human-readable error message suitable for end-user display. (example: "X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.") - **code** (required) `string`: Machine-readable error code. Stable across versions. - one of: PAYMENT_REQUIRED - **reason** (required) `string`: Discriminator for which gate fired. - one of: free_tier_exceeded, twitter_passthrough, enterprise_required - **documentation_url** `string` (uri): Link to the relevant documentation page. (example: "https://docs.zernio.com/billing/payment-method-required") - **dashboard_url** `string` (uri): Deep-link to send the end-user to. For `free_tier_exceeded` and `twitter_passthrough` this is the Zernio billing tab. For `enterprise_required` this is the Zernio enterprise contact page. (example: "https://zernio.com/dashboard?tab=billing") - **details** `object`: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`. - **free_tier_account_limit** `integer`: How many accounts the free tier allows. Only set when reason=free_tier_exceeded. (example: 2) - **current_account_count** `integer`: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required. (example: 5) - **has_payment_method** `boolean`: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough. - **public_account_limit** `integer`: Public pricing ceiling (the published cap beyond which an enterprise contract is required). Only set when reason=enterprise_required. (example: 2000) - **effective_account_limit** `integer`: The cap actually applied to this team. Equals `public_account_limit` for organic teams; for teams with a per-customer override (grandfathered legacy customers, signed enterprise contracts) this can be higher. Only set when reason=enterprise_required. (example: 2000) #### 403: No access to profile, or BYOK required for AppSumo Twitter #### 404: Profile not found --- ## POST /v1/connect/{platform} **Complete OAuth callback** Exchange the OAuth authorization code for tokens and connect the account to the specified profile. ### Parameters - **platform** (required) in path: No description ### Request Body - **code** (required) `string`: No description - **state** (required) `string`: No description - **profileId** (required) `string`: No description ### Responses #### 200: Account connected #### 400: Invalid params #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: BYOK required for AppSumo Twitter #### 500: Failed to connect account --- --- # Connect Telegram directly API Reference 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. ## GET /v1/connect/telegram **Generate Telegram code** 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 - **profileId** (required) in query: The profile ID to connect the Telegram account to ### Responses #### 200: Access code generated **Response Body:** - **code** `string`: The access code to send to the Telegram bot (example: "ZRN-ABC123") - **expiresAt** `string` (date-time): When the code expires - **expiresIn** `integer`: Seconds until expiration (example: 900) - **botUsername** `string`: The Telegram bot username to message (example: "LateScheduleBot") - **instructions** `array[string]`: Step-by-step connection instructions #### 400: Profile ID required or invalid format #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## POST /v1/connect/telegram **Connect Telegram directly** 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. ### Request Body - **chatId** (required) `string`: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). - **profileId** (required) `string`: The profile ID to connect the account to ### Responses #### 200: Telegram channel connected successfully **Response Body:** - **message** `string`: No description - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - one of: telegram - **username** `string`: No description - **displayName** `string`: No description - **isActive** `boolean`: No description - **chatType** `string`: No description - one of: channel, group, supergroup, private #### 400: Chat ID required #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to this profile #### 404: Profile not found #### 500: Internal error --- ## PATCH /v1/connect/telegram **Check Telegram status** 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 - **code** (required) in query: The access code to check status for ### Responses #### 200: Connection status **Response Body:** *One of the following:* - **status** `string`: No description - one of: pending - **expiresAt** `string` (date-time): No description - **expiresIn** `integer`: Seconds until expiration - **status** `string`: No description - one of: connected - **chatId** `string`: No description - **chatTitle** `string`: No description - **chatType** `string`: No description - one of: channel, group, supergroup - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **status** `string`: No description - one of: expired - **message** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Code not found #### 500: Internal error --- --- # List Facebook pages API Reference 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. ## GET /v1/connect/facebook/select-page **List Facebook pages** 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 - **profileId** (required) in query: Profile ID from your connection flow - **tempToken** (required) in query: Temporary Facebook access token from the OAuth callback redirect ### Responses #### 200: List of Facebook Pages available for connection **Response Body:** - **pages** `array[object]`: - **id** `string`: Facebook Page ID - **name** `string`: Page name - **username** `string`: Page username/handle (may be null) - **access_token** `string`: Page-specific access token - **category** `string`: Page category - **tasks** `array[string]`: User permissions for this page #### 400: Missing required parameters (profileId or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to fetch pages (e.g., invalid token, insufficient permissions) **Response Body:** - **error** `string`: No description --- ## POST /v1/connect/facebook/select-page **Select Facebook page** 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. ### Request Body - **profileId** (required) `string`: Profile ID from your connection flow - **pageId** (required) `string`: The Facebook Page ID selected by the user - **tempToken** (required) `string`: Temporary Facebook access token from OAuth - **userProfile** (required) `object`: Decoded user profile object from the OAuth callback - **redirect_url** `string`: Optional custom redirect URL to return to after selection ### Responses #### 200: Facebook Page connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL if custom redirect_url was provided - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: facebook - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **selectedPageName** `string`: No description #### 400: Missing required fields (profileId, pageId, tempToken, or userProfile) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: User does not have access to the specified profile #### 404: Selected page not found in available pages #### 500: Failed to save Facebook connection --- --- # List GBP locations API Reference 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. ## GET /v1/connect/googlebusiness/locations **List GBP locations** 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 - **profileId** (optional) in query: Profile ID from your connection flow. Required for auth validation when provided. - **pendingDataToken** (optional) in query: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required. - **tempToken** (optional) in query: Legacy. Direct Google access token. Use pendingDataToken instead when available. - **search** (optional) in query: Free-text search on the business name, applied server-side by Google. Use this for accounts that own many locations (the response is bounded, see hasMore) so the user can find a specific location without loading the full list. - **filter** (optional) in query: Raw Google Business Information API filter expression (advanced; takes precedence over search). Supports fields such as title, storeCode, storefront_address.postal_code, labels and categories, e.g. storeCode="LH279411". See Google's "Work with location data" guide. ### Responses #### 200: List of Google Business locations available for connection **Response Body:** - **locations** `array[object]`: - **id** `string`: Location ID - **name** `string`: Business name - **accountId** `string`: Google Business Account ID - **accountName** `string`: Account name - **address** `string`: Business address - **category** `string`: Business category - **storeCode** `string`: Store code set on the location in Google Business Profile (if any) - **hasMore** `boolean`: True when more locations exist than were returned (the list is bounded). Prompt the user to narrow the result set with search. #### 400: Missing required parameters (profileId or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to fetch locations (e.g., invalid token, insufficient permissions) **Response Body:** - **error** `string`: No description --- --- # List LinkedIn orgs API Reference Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth. ## GET /v1/connect/linkedin/organizations **List LinkedIn orgs** Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth. ### Parameters - **tempToken** (required) in query: The temporary LinkedIn access token from the OAuth redirect - **orgIds** (required) in query: Comma-separated list of organization IDs to fetch details for (max 100) ### Responses #### 200: Organization details fetched successfully **Response Body:** - **organizations** `array[object]`: - **id** `string`: Organization ID - **logoUrl** `string` (uri): Logo URL (may be absent if no logo) - **vanityName** `string`: Organization's vanity name/slug - **website** `string` (uri): Organization's website URL - **industry** `string`: Organization's primary industry - **description** `string`: Organization's description #### 400: Missing required parameters or too many organization IDs **Response Body:** - **error** `string`: No description #### 500: Failed to fetch organization details --- --- # List Pinterest boards API Reference For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL. ## GET /v1/connect/pinterest/select-board **List Pinterest boards** For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL. ### Parameters - **X-Connect-Token** (required) in header: Short-lived connect token from the OAuth redirect - **profileId** (required) in query: Your Zernio profile ID - **tempToken** (required) in query: Temporary Pinterest access token from the OAuth callback redirect ### Responses #### 200: List of Pinterest Boards available for connection **Response Body:** - **boards** `array[object]`: - **id** `string`: Pinterest Board ID - **name** `string`: Board name - **description** `string`: Board description - **privacy** `string`: Board privacy setting #### 400: Missing required parameters #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile #### 500: Failed to fetch boards --- ## POST /v1/connect/pinterest/select-board **Select Pinterest board** 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. ### Request Body - **profileId** (required) `string`: Your Zernio profile ID - **boardId** (required) `string`: The Pinterest Board ID selected by the user - **boardName** `string`: The board name (for display purposes) - **tempToken** (required) `string`: Temporary Pinterest access token from OAuth - **userProfile** `object`: User profile data from OAuth redirect - **refreshToken** `string`: Pinterest refresh token (if available) - **expiresIn** `integer`: Token expiration time in seconds - **redirect_url** `string`: Custom redirect URL after connection completes ### Responses #### 200: Pinterest Board connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL with connection params (if provided) - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: pinterest - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **defaultBoardName** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile or profile limit exceeded #### 500: Failed to save Pinterest connection --- --- # List Snapchat profiles API Reference For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL. ## GET /v1/connect/snapchat/select-profile **List Snapchat profiles** For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL. ### Parameters - **X-Connect-Token** (required) in header: Short-lived connect token from the OAuth redirect - **profileId** (required) in query: Your Zernio profile ID - **tempToken** (required) in query: Temporary Snapchat access token from the OAuth callback redirect ### Responses #### 200: List of Snapchat Public Profiles available for connection **Response Body:** - **publicProfiles** `array[object]`: - **id** `string`: Snapchat Public Profile ID - **display_name** `string`: Public profile display name - **username** `string`: Public profile username/handle - **profile_image_url** `string`: Profile image URL - **subscriber_count** `integer`: Number of subscribers #### 400: Missing required parameters (profileId or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile #### 500: Failed to fetch public profiles --- ## POST /v1/connect/snapchat/select-profile **Select Snapchat profile** 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 - **X-Connect-Token** (optional) in header: Short-lived connect token from the OAuth redirect (for API users) ### Request Body - **profileId** (required) `string`: Your Zernio profile ID - **selectedPublicProfile** (required) `object`: The selected Snapchat Public Profile - **tempToken** (required) `string`: Temporary Snapchat access token from OAuth - **userProfile** (required) `object`: User profile data from OAuth redirect - **refreshToken** `string`: Snapchat refresh token (if available) - **expiresIn** `integer`: Token expiration time in seconds - **redirect_url** `string`: Custom redirect URL after connection completes ### Responses #### 200: Snapchat Public Profile connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL with connection params (if provided in request) - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: snapchat - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **publicProfileName** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile or profile limit exceeded #### 500: Failed to connect Snapchat account --- --- # List WhatsApp phone numbers for selection API Reference Fetch the WhatsApp phone numbers available across the user's WhatsApp Business Accounts (WABAs) after a headless OAuth flow. WhatsApp OAuth grants access at the WABA level. When a connected WABA has 2 or more phone numbers, you must call this endpoint to list them and then `POST /v1/connect/whatsapp/select-phone-number` to bind one to the Zernio profile. Single-phone WABAs auto-complete during the OAuth callback and never reach this endpoint. Use the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`). Alternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow. ## GET /v1/connect/whatsapp/select-phone-number **List WhatsApp phone numbers for selection** Fetch the WhatsApp phone numbers available across the user's WhatsApp Business Accounts (WABAs) after a headless OAuth flow. WhatsApp OAuth grants access at the WABA level. When a connected WABA has 2 or more phone numbers, you must call this endpoint to list them and then `POST /v1/connect/whatsapp/select-phone-number` to bind one to the Zernio profile. Single-phone WABAs auto-complete during the OAuth callback and never reach this endpoint. Use the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`). Alternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow. ### Parameters - **profileId** (required) in query: The Zernio profile ID from the headless redirect - **tempToken** (required) in query: The temporary access token from the headless redirect - **X-Connect-Token** (optional) in header: Alternative auth for API users' end customers (used when the bearer token is scoped to a different user) ### Responses #### 200: Phone numbers fetched successfully **Response Body:** - **phoneNumbers** `array[object]`: - **id** `string`: Phone Number ID (Meta) - **display_phone_number** `string`: E.164-formatted display number - **verified_name** `string`: Meta-verified business name - **quality_rating** `string`: GREEN, YELLOW, RED, or UNKNOWN - **name_status** `string`: APPROVED, PENDING_REVIEW, DECLINED, or NONE - **messaging_limit_tier** `string`: TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED - **wabaId** `string`: WhatsApp Business Account ID (Zernio enrichment) - **wabaName** `string`: WABA display name (Zernio enrichment) #### 400: Missing profileId or tempToken **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to fetch phone numbers (Meta API error, expired token, or insufficient permissions) **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/connect/whatsapp/select-phone-number **Complete WhatsApp phone number selection** Bind a specific WhatsApp phone number to the Zernio profile after the user picks one from `listWhatsAppPhoneNumbers`. Exchanges the short-lived OAuth token for a long-lived token, subscribes the WABA to webhooks, and creates the SocialAccount. ### Parameters - **X-Connect-Token** (optional) in header: Alternative auth for API users' end customers ### Request Body - **profileId** (required) `string`: The Zernio profile ID - **phoneNumberId** (required) `string`: The selected phone number ID (from listWhatsAppPhoneNumbers) - **wabaId** (required) `string`: The WABA ID containing the selected phone - **tempToken** (required) `string`: The temporary access token from the headless redirect - **userProfile** `object`: Optional user profile data (passthrough) - **redirect_url** `string`: Optional URL to receive the post-connection redirect target ### Responses #### 200: Phone number connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Present only if redirect_url was provided in the request - **account** `object`: - **accountId** `string`: No description - **platform** `string`: No description - one of: whatsapp - **username** `string`: Display phone number - **displayName** `string`: Meta-verified business name - **isActive** `boolean`: No description - **selectedPhoneNumber** `string`: No description #### 400: Missing required fields (profileId, phoneNumberId, wabaId, or tempToken) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Profile limit exceeded for the user's plan (PROFILE_LIMIT_EXCEEDED) #### 404: Selected phone number not found in the specified WABA **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 500: Failed to bind phone number **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Select Facebook page API Reference 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. ## GET /v1/connect/facebook/select-page **List Facebook pages** 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 - **profileId** (required) in query: Profile ID from your connection flow - **tempToken** (required) in query: Temporary Facebook access token from the OAuth callback redirect ### Responses #### 200: List of Facebook Pages available for connection **Response Body:** - **pages** `array[object]`: - **id** `string`: Facebook Page ID - **name** `string`: Page name - **username** `string`: Page username/handle (may be null) - **access_token** `string`: Page-specific access token - **category** `string`: Page category - **tasks** `array[string]`: User permissions for this page #### 400: Missing required parameters (profileId or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to fetch pages (e.g., invalid token, insufficient permissions) **Response Body:** - **error** `string`: No description --- ## POST /v1/connect/facebook/select-page **Select Facebook page** 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. ### Request Body - **profileId** (required) `string`: Profile ID from your connection flow - **pageId** (required) `string`: The Facebook Page ID selected by the user - **tempToken** (required) `string`: Temporary Facebook access token from OAuth - **userProfile** (required) `object`: Decoded user profile object from the OAuth callback - **redirect_url** `string`: Optional custom redirect URL to return to after selection ### Responses #### 200: Facebook Page connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL if custom redirect_url was provided - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: facebook - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **selectedPageName** `string`: No description #### 400: Missing required fields (profileId, pageId, tempToken, or userProfile) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: User does not have access to the specified profile #### 404: Selected page not found in available pages #### 500: Failed to save Facebook connection --- --- # Select GBP location API Reference 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. ## POST /v1/connect/googlebusiness/select-location **Select GBP location** 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. ### Request Body - **profileId** (required) `string`: Profile ID from your connection flow - **locationId** (required) `string`: The Google Business location ID selected by the user - **accountId** `string`: Optional but recommended. The Google Business Account resource name ("accounts/123") that owns the selected location (returned per-location by GET /v1/connect/googlebusiness/locations). When provided, the location is resolved directly instead of by enumerating the account, which is required for accounts that own many locations. Omit only for small accounts. - **pendingDataToken** (required) `string`: Token from the OAuth callback redirect (pendingDataToken query param). Tokens and profile data are retrieved server-side from this token. - **redirect_url** `string`: Optional custom redirect URL to return to after selection ### Responses #### 200: Google Business location connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL if custom redirect_url was provided - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: googlebusiness - **username** `string`: No description - **displayName** `string`: No description - **isActive** `boolean`: No description - **selectedLocationName** `string`: No description - **selectedLocationId** `string`: No description #### 400: Missing required fields (profileId, locationId, or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: User does not have access to the specified profile #### 404: Selected location not found in available locations #### 500: Failed to save Google Business connection --- --- # Select LinkedIn org API Reference 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. ## POST /v1/connect/linkedin/select-organization **Select LinkedIn org** 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. ### Request Body - **profileId** (required) `string`: No description - **tempToken** (required) `string`: No description - **userProfile** (required) `object`: No description - **accountType** (required) `string`: No description - one of: personal, organization - **selectedOrganization** `object`: No description - **redirect_url** `string`: No description ### Responses #### 200: LinkedIn account connected **Response Body:** - **message** `string`: No description - **redirect_url** `string`: The redirect URL with connection params appended (only if redirect_url was provided in request) - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: linkedin - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **accountType** `string`: No description - one of: personal, organization - **bulkRefresh** `object`: - **updatedCount** `integer`: No description - **errors** `integer`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Failed to connect LinkedIn account --- --- # Select Pinterest board API Reference 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. ## GET /v1/connect/pinterest/select-board **List Pinterest boards** For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL. ### Parameters - **X-Connect-Token** (required) in header: Short-lived connect token from the OAuth redirect - **profileId** (required) in query: Your Zernio profile ID - **tempToken** (required) in query: Temporary Pinterest access token from the OAuth callback redirect ### Responses #### 200: List of Pinterest Boards available for connection **Response Body:** - **boards** `array[object]`: - **id** `string`: Pinterest Board ID - **name** `string`: Board name - **description** `string`: Board description - **privacy** `string`: Board privacy setting #### 400: Missing required parameters #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile #### 500: Failed to fetch boards --- ## POST /v1/connect/pinterest/select-board **Select Pinterest board** 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. ### Request Body - **profileId** (required) `string`: Your Zernio profile ID - **boardId** (required) `string`: The Pinterest Board ID selected by the user - **boardName** `string`: The board name (for display purposes) - **tempToken** (required) `string`: Temporary Pinterest access token from OAuth - **userProfile** `object`: User profile data from OAuth redirect - **refreshToken** `string`: Pinterest refresh token (if available) - **expiresIn** `integer`: Token expiration time in seconds - **redirect_url** `string`: Custom redirect URL after connection completes ### Responses #### 200: Pinterest Board connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL with connection params (if provided) - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: pinterest - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **defaultBoardName** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile or profile limit exceeded #### 500: Failed to save Pinterest connection --- --- # Select Snapchat profile API Reference 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. ## GET /v1/connect/snapchat/select-profile **List Snapchat profiles** For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL. ### Parameters - **X-Connect-Token** (required) in header: Short-lived connect token from the OAuth redirect - **profileId** (required) in query: Your Zernio profile ID - **tempToken** (required) in query: Temporary Snapchat access token from the OAuth callback redirect ### Responses #### 200: List of Snapchat Public Profiles available for connection **Response Body:** - **publicProfiles** `array[object]`: - **id** `string`: Snapchat Public Profile ID - **display_name** `string`: Public profile display name - **username** `string`: Public profile username/handle - **profile_image_url** `string`: Profile image URL - **subscriber_count** `integer`: Number of subscribers #### 400: Missing required parameters (profileId or tempToken) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile #### 500: Failed to fetch public profiles --- ## POST /v1/connect/snapchat/select-profile **Select Snapchat profile** 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 - **X-Connect-Token** (optional) in header: Short-lived connect token from the OAuth redirect (for API users) ### Request Body - **profileId** (required) `string`: Your Zernio profile ID - **selectedPublicProfile** (required) `object`: The selected Snapchat Public Profile - **tempToken** (required) `string`: Temporary Snapchat access token from OAuth - **userProfile** (required) `object`: User profile data from OAuth redirect - **refreshToken** `string`: Snapchat refresh token (if available) - **expiresIn** `integer`: Token expiration time in seconds - **redirect_url** `string`: Custom redirect URL after connection completes ### Responses #### 200: Snapchat Public Profile connected successfully **Response Body:** - **message** `string`: No description - **redirect_url** `string`: Redirect URL with connection params (if provided in request) - **account** `object`: - **accountId** `string`: ID of the created SocialAccount - **platform** `string`: No description - one of: snapchat - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **isActive** `boolean`: No description - **publicProfileName** `string`: No description #### 400: Missing required fields #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: No access to profile or profile limit exceeded #### 500: Failed to connect Snapchat account --- --- # Update Facebook page API Reference Switch which Facebook Page is active for a connected account. ## GET /v1/accounts/{accountId}/facebook-page **List Facebook pages** Returns all Facebook pages the connected account has access to, including the currently selected page. ### Parameters - **accountId** (required) in path: No description - **refresh** (optional) in query: When true, bypasses the page cache and fetches fresh pages from Meta. Rate-limited server-side to 1 refresh per 60s. Pages no longer accessible to the connected account will be removed from the list on refresh. ### Responses #### 200: Pages list **Response Body:** - **pages** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **username** `string`: No description - **category** `string`: No description - **fan_count** `integer`: No description - **selectedPageId** `string`: No description - **cached** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/facebook-page **Update Facebook page** Switch which Facebook Page is active for a connected account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **selectedPageId** (required) `string`: No description ### Responses #### 200: Page updated **Response Body:** - **message** `string`: No description - **selectedPage** `object`: - **id** `string`: No description - **name** `string`: No description #### 400: Page not in available pages #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Update GBP location API Reference Switch which GBP location is active for a connected account. ## GET /v1/accounts/{accountId}/gmb-locations **List GBP locations** Returns Google Business Profile locations the connected account can access, plus the currently selected location. The list is bounded (see hasMore); for accounts that own many locations, use the search or filter query params to find a specific one instead of loading them all. ### Parameters - **accountId** (required) in path: No description - **search** (optional) in query: Free-text search on the business name, applied server-side by Google. Use for accounts with many locations. - **filter** (optional) in query: Raw Google Business Information API filter expression (advanced; takes precedence over search), e.g. storeCode="LH279411". ### Responses #### 200: Locations list **Response Body:** - **locations** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **accountId** `string`: No description - **accountName** `string`: No description - **address** `string`: No description - **category** `string`: No description - **websiteUrl** `string`: No description - **storeCode** `string`: No description - **hasMore** `boolean`: True when more locations exist than were returned (use search to narrow down). - **selectedLocationId** `string`: No description - **cached** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/gmb-locations **Update GBP location** Switch which GBP location is active for a connected account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **selectedLocationId** (required) `string`: No description - **googleAccountId** `string`: Optional but recommended. The Google Business Account resource name ("accounts/123") that owns the new location (from GET gmb-locations). When provided, the location is resolved directly instead of by enumerating the account, which is required for accounts with many locations. Named `googleAccountId` to disambiguate from the path `accountId` (the Zernio account). The legacy field name `accountId` is still accepted for backwards compatibility. ### Responses #### 200: Location updated **Response Body:** - **message** `string`: No description - **selectedLocation** `object`: - **id** `string`: No description - **name** `string`: No description #### 400: Location not in available locations #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Switch LinkedIn account type API Reference Switch a LinkedIn account between personal profile and organization (company page) posting. ## PUT /v1/accounts/{accountId}/linkedin-organization **Switch LinkedIn account type** Switch a LinkedIn account between personal profile and organization (company page) posting. ### Parameters - **accountId** (required) in path: No description ### Request Body - **accountType** (required) `string`: No description - one of: personal, organization - **selectedOrganization** `object`: No description ### Responses #### 200: Account updated **Response Body:** - **message** `string`: No description - **account**: `SocialAccount` - See schema definition #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- # Related Schema Definitions ## SocialAccount ### Properties - **_id** (required) `string`: No description - **platform** (required) `string`: No description - one of: tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads - **profileId** (required): No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: URL to the account's profile picture on the platform. May be null if the platform does not provide one. - **profileUrl** `string`: Full profile URL for the connected account on its platform. - **isActive** (required) `boolean`: No description - **followersCount** `number`: Follower count (only included if user has analytics add-on) - **followersLastUpdated** `string`: Last time follower count was updated (only included if user has analytics add-on) - **parentAccountId** `string`: 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** `boolean`: 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** `object`: 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 --- # Set default Pinterest board API Reference Sets the default board used when publishing pins for this account. ## GET /v1/accounts/{accountId}/pinterest-boards **List Pinterest boards** Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Boards list **Response Body:** - **boards** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **privacy** `string`: No description #### 400: Not a Pinterest account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/pinterest-boards **Set default Pinterest board** Sets the default board used when publishing pins for this account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **defaultBoardId** (required) `string`: No description - **defaultBoardName** `string`: No description ### Responses #### 200: Default board set **Response Body:** - **message** `string`: No description - **account**: `SocialAccount` - See schema definition #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- # Related Schema Definitions ## SocialAccount ### Properties - **_id** (required) `string`: No description - **platform** (required) `string`: No description - one of: tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads - **profileId** (required): No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: URL to the account's profile picture on the platform. May be null if the platform does not provide one. - **profileUrl** `string`: Full profile URL for the connected account on its platform. - **isActive** (required) `boolean`: No description - **followersCount** `number`: Follower count (only included if user has analytics add-on) - **followersLastUpdated** `string`: Last time follower count was updated (only included if user has analytics add-on) - **parentAccountId** `string`: 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** `boolean`: 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** `object`: 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 --- # Set default subreddit API Reference Sets the default subreddit used when publishing posts for this Reddit account. ## GET /v1/accounts/{accountId}/reddit-subreddits **List Reddit subreddits** Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Subreddits list **Response Body:** - **subreddits** `array[object]`: - **id** `string`: Reddit subreddit ID - **name** `string`: Subreddit name without r/ prefix - **title** `string`: Subreddit title - **url** `string`: Subreddit URL path - **over18** `boolean`: Whether the subreddit is NSFW - **defaultSubreddit** `string`: Currently set default subreddit for posting #### 400: Not a Reddit account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/reddit-subreddits **Set default subreddit** Sets the default subreddit used when publishing posts for this Reddit account. ### Parameters - **accountId** (required) in path: No description ### Request Body - **defaultSubreddit** (required) `string`: No description ### Responses #### 200: Default subreddit set **Response Body:** - **success** `boolean`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Set default YouTube playlist API Reference 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). ## GET /v1/accounts/{accountId}/youtube-playlists **List YouTube playlists** 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 - **accountId** (required) in path: No description ### Responses #### 200: Playlists list **Response Body:** - **playlists** `array[object]`: - **id** `string`: No description - **title** `string`: No description - **description** `string`: No description - **privacy** `string`: No description - one of: public, private, unlisted - **itemCount** `integer`: No description - **thumbnailUrl** `string`: No description - **defaultPlaylistId** `string`: No description #### 400: Not a YouTube account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PUT /v1/accounts/{accountId}/youtube-playlists **Set default YouTube playlist** 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 - **accountId** (required) in path: No description ### Request Body - **defaultPlaylistId** (required) `string`: No description - **defaultPlaylistName** `string`: No description ### Responses #### 200: Default playlist set **Response Body:** - **success** `boolean`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Clear custom field value API Reference Remove a custom field value from a contact. The field definition is not affected. ## PUT /v1/contacts/{contactId}/fields/{slug} **Set custom field value** Set or overwrite a custom field value on a contact. The value type must match the field definition. ### Parameters - **contactId** (required) in path: No description - **slug** (required) in path: No description ### Request Body - **value** (required): Field value (type depends on field definition) ### Responses #### 200: Field value set #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/contacts/{contactId}/fields/{slug} **Clear custom field value** Remove a custom field value from a contact. The field definition is not affected. ### Parameters - **contactId** (required) in path: No description - **slug** (required) in path: No description ### Responses #### 200: Field value cleared #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Create custom field API Reference Create a new custom field definition. Supported types are text, number, date, boolean, and select. ## GET /v1/custom-fields **List custom field definitions** Returns all custom field definitions. Optionally filter by profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles ### Responses #### 200: List of custom field definitions **Response Body:** - **success** `boolean`: No description - **fields** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - one of: text, number, date, boolean, select - **options** `array[string]`: - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/custom-fields **Create custom field** Create a new custom field definition. Supported types are text, number, date, boolean, and select. ### Request Body - **profileId** (required) `string`: No description - **name** (required) `string`: No description - **slug** `string`: Auto-generated from name if not provided - **type** (required) `string`: No description - one of: text, number, date, boolean, select - **options** `array`: Required for select type ### Responses #### 200: Custom field created **Response Body:** - **success** `boolean`: No description - **field** `object`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - one of: text, number, date, boolean, select - **options** `array[string]`: - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Duplicate slug --- --- # Delete custom field API Reference Delete a custom field definition and remove its values from all contacts. ## PATCH /v1/custom-fields/{fieldId} **Update custom field** Update a custom field definition. The field type cannot be changed after creation. ### Parameters - **fieldId** (required) in path: No description ### Request Body - **name** `string`: No description - **options** `array`: No description ### Responses #### 200: Custom field updated **Response Body:** - **success** `boolean`: No description - **field** `object`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - **options** `array[string]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/custom-fields/{fieldId} **Delete custom field** Delete a custom field definition and remove its values from all contacts. ### Parameters - **fieldId** (required) in path: No description ### Responses #### 200: Custom field deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List custom field definitions API Reference Returns all custom field definitions. Optionally filter by profile. ## GET /v1/custom-fields **List custom field definitions** Returns all custom field definitions. Optionally filter by profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles ### Responses #### 200: List of custom field definitions **Response Body:** - **success** `boolean`: No description - **fields** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - one of: text, number, date, boolean, select - **options** `array[string]`: - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/custom-fields **Create custom field** Create a new custom field definition. Supported types are text, number, date, boolean, and select. ### Request Body - **profileId** (required) `string`: No description - **name** (required) `string`: No description - **slug** `string`: Auto-generated from name if not provided - **type** (required) `string`: No description - one of: text, number, date, boolean, select - **options** `array`: Required for select type ### Responses #### 200: Custom field created **Response Body:** - **success** `boolean`: No description - **field** `object`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - one of: text, number, date, boolean, select - **options** `array[string]`: - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 409: Duplicate slug --- --- # Set custom field value API Reference Set or overwrite a custom field value on a contact. The value type must match the field definition. ## PUT /v1/contacts/{contactId}/fields/{slug} **Set custom field value** Set or overwrite a custom field value on a contact. The value type must match the field definition. ### Parameters - **contactId** (required) in path: No description - **slug** (required) in path: No description ### Request Body - **value** (required): Field value (type depends on field definition) ### Responses #### 200: Field value set #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/contacts/{contactId}/fields/{slug} **Clear custom field value** Remove a custom field value from a contact. The field definition is not affected. ### Parameters - **contactId** (required) in path: No description - **slug** (required) in path: No description ### Responses #### 200: Field value cleared #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Update custom field API Reference Update a custom field definition. The field type cannot be changed after creation. ## PATCH /v1/custom-fields/{fieldId} **Update custom field** Update a custom field definition. The field type cannot be changed after creation. ### Parameters - **fieldId** (required) in path: No description ### Request Body - **name** `string`: No description - **options** `array`: No description ### Responses #### 200: Custom field updated **Response Body:** - **success** `boolean`: No description - **field** `object`: - **id** `string`: No description - **name** `string`: No description - **slug** `string`: No description - **type** `string`: No description - **options** `array[string]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/custom-fields/{fieldId} **Delete custom field** Delete a custom field definition and remove its values from all contacts. ### Parameters - **fieldId** (required) in path: No description ### Responses #### 200: Custom field deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Assign a role to a guild member API Reference Assign one role to one member. Idempotent on Discord's side — re-running on a member who already has the role is a 204 no-op. Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`) for zero-translation mental mapping. Bot needs MANAGE_ROLES permission in the guild AND its highest role must be above the target role (Discord hierarchy rule). The `@everyone` role (where roleId == guildId) cannot be assigned. ## PUT /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId} **Assign a role to a guild member** Assign one role to one member. Idempotent on Discord's side — re-running on a member who already has the role is a 204 no-op. Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`) for zero-translation mental mapping. Bot needs MANAGE_ROLES permission in the guild AND its highest role must be above the target role (Discord hierarchy rule). The `@everyone` role (where roleId == guildId) cannot be assigned. ### Parameters - **guildId** (required) in path: No description - **userId** (required) in path: Discord user snowflake to assign the role to. - **roleId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Role assigned (or already present — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: role_assigned - **guildId** `string`: No description - **userId** `string`: No description - **roleId** `string`: No description #### 400: Validation error (malformed snowflake) or @everyone manipulation attempt. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Discord rejected the request: bot lacks MANAGE_ROLES, or target role is above the bot's highest role. --- ## DELETE /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId} **Remove a role from a guild member** Remove one role from one member. Idempotent — removing a role the member doesn't have returns 204 no-op. Same permission + hierarchy constraints as the PUT counterpart. ### Parameters - **guildId** (required) in path: No description - **userId** (required) in path: No description - **roleId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Role removed (or was already absent — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: role_removed - **guildId** `string`: No description - **userId** `string`: No description - **roleId** `string`: No description #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Discord rejected the request (permission or hierarchy issue). --- --- # Create a Discord scheduled event API Reference Create a guild scheduled event. Three event types, selected via the discriminator on `entity.type`: - `external` — off-platform (Zoom, in-person, livestream). Requires both `location` and `endsAt`. Most common type for scheduler integrations. - `voice` — hosted in a Discord voice channel. Requires `channelId`. - `stage` — hosted in a Discord stage channel. Requires `channelId`. Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events PR) need a re-invite OR a server admin manually granting the permission — see route header for details. ## GET /v1/discord/guilds/{guildId}/events **List Discord scheduled events** Return all scheduled events in the guild. Events are distinct from messages — they appear in the server's Events panel and Discord auto-notifies interested members ahead of start time. Pass `withUserCount=true` to include `user_count` (number of members who RSVP'd) on each event. Useful for surfacing engagement. ### Parameters - **guildId** (required) in path: No description - **accountId** (required) in query: No description - **withUserCount** (optional) in query: Include user_count on each event. ### Responses #### 200: List of scheduled events. **Response Body:** - **data** `array[DiscordScheduledEvent]`: #### 400: Invalid params. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Bot lacks access to the guild's events. --- ## POST /v1/discord/guilds/{guildId}/events **Create a Discord scheduled event** Create a guild scheduled event. Three event types, selected via the discriminator on `entity.type`: - `external` — off-platform (Zoom, in-person, livestream). Requires both `location` and `endsAt`. Most common type for scheduler integrations. - `voice` — hosted in a Discord voice channel. Requires `channelId`. - `stage` — hosted in a Discord stage channel. Requires `channelId`. Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events PR) need a re-invite OR a server admin manually granting the permission — see route header for details. ### Parameters - **guildId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **name** (required) `string`: No description - **description** `string`: No description - **startsAt** (required) `string`: ISO 8601 start time. Must be in the future. - **entity**: Platform-specific settings (see schema definitions below) - **imageDataUri** `string`: Optional cover image as a base64 data URI. ### Responses #### 200: Event created. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 400: Validation error (missing required fields for the chosen entity type, malformed snowflake, past startsAt, etc.). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_EVENTS in the guild. --- # Related Schema Definitions ## DiscordScheduledEvent Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints. Fields below are the subset Zernio consumes — Discord may return more (e.g. creator, image hash) which we pass through verbatim. ### Properties - **id** `string`: Event snowflake ID - **guild_id** `string`: No description - **channel_id** `string`: Voice/stage channel ID; null for external events. - **creator_id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **scheduled_start_time** `string`: No description - **scheduled_end_time** `string`: Required for external events; optional for voice/stage. - **privacy_level** `integer`: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. - one of: 2 - **status** `integer`: 1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED - one of: 1, 2, 3, 4 - **entity_type** `integer`: 1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL - one of: 1, 2, 3 - **entity_id** `string`: No description - **entity_metadata** `object`: - **location** `string`: External event location string. - **user_count** `integer`: Number of members who RSVP'd. Only present when withUserCount=true on list. - **image** `string`: Cover image hash; build URL via cdn.discordapp.com. --- # Delete a Discord scheduled event API Reference Hard-delete an event. Use PATCH with `status: 'cancelled'` instead if you want the event preserved in the guild's history. ## GET /v1/discord/guilds/{guildId}/events/{eventId} **Get a Discord scheduled event** ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. --- ## PATCH /v1/discord/guilds/{guildId}/events/{eventId} **Update a Discord scheduled event** Patch any subset of fields. Passing `status: 'cancelled'` is how you cancel an event — Discord doesn't have a dedicated cancel endpoint, it's a status transition. Most status transitions Discord enforces (you can't go SCHEDULED → COMPLETED directly). The common consumer case is SCHEDULED → CANCELED. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **name** `string`: No description - **description** `string`: No description - **startsAt** `string`: No description - **endsAt** `string`: No description - **location** `string`: For external events. - **status** `string`: Status transition. Most common: 'cancelled' to cancel an event. - one of: scheduled, active, completed, cancelled - **imageDataUri** `string`: No description ### Responses #### 200: Event updated. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 400: Validation error, OR no updatable fields beyond accountId provided. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Discord rejected the update (invalid status transition --- ## DELETE /v1/discord/guilds/{guildId}/events/{eventId} **Delete a Discord scheduled event** Hard-delete an event. Use PATCH with `status: 'cancelled'` instead if you want the event preserved in the guild's history. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event deleted. **Response Body:** - **success** `boolean`: No description - **deleted** `string`: The deleted event's snowflake. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Bot lacks MANAGE_EVENTS in the guild. --- # Related Schema Definitions ## DiscordScheduledEvent Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints. Fields below are the subset Zernio consumes — Discord may return more (e.g. creator, image hash) which we pass through verbatim. ### Properties - **id** `string`: Event snowflake ID - **guild_id** `string`: No description - **channel_id** `string`: Voice/stage channel ID; null for external events. - **creator_id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **scheduled_start_time** `string`: No description - **scheduled_end_time** `string`: Required for external events; optional for voice/stage. - **privacy_level** `integer`: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. - one of: 2 - **status** `integer`: 1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED - one of: 1, 2, 3, 4 - **entity_type** `integer`: 1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL - one of: 1, 2, 3 - **entity_id** `string`: No description - **entity_metadata** `object`: - **location** `string`: External event location string. - **user_count** `integer`: Number of members who RSVP'd. Only present when withUserCount=true on list. - **image** `string`: Cover image hash; build URL via cdn.discordapp.com. --- # List Discord guild channels API Reference 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. ## GET /v1/accounts/{accountId}/discord-channels **List Discord guild channels** 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 - **accountId** (required) in path: No description ### Responses #### 200: Channel list **Response Body:** - **channels** `array[object]`: - **id** `string`: Channel snowflake ID - **name** `string`: Channel name - **type** `integer`: Channel type: 0 (text), 5 (announcement), 15 (forum) #### 400: Not a Discord account or missing guild info #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- --- # Get a Discord scheduled event API Reference ## GET /v1/discord/guilds/{guildId}/events/{eventId} **Get a Discord scheduled event** ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. --- ## PATCH /v1/discord/guilds/{guildId}/events/{eventId} **Update a Discord scheduled event** Patch any subset of fields. Passing `status: 'cancelled'` is how you cancel an event — Discord doesn't have a dedicated cancel endpoint, it's a status transition. Most status transitions Discord enforces (you can't go SCHEDULED → COMPLETED directly). The common consumer case is SCHEDULED → CANCELED. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **name** `string`: No description - **description** `string`: No description - **startsAt** `string`: No description - **endsAt** `string`: No description - **location** `string`: For external events. - **status** `string`: Status transition. Most common: 'cancelled' to cancel an event. - one of: scheduled, active, completed, cancelled - **imageDataUri** `string`: No description ### Responses #### 200: Event updated. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 400: Validation error, OR no updatable fields beyond accountId provided. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Discord rejected the update (invalid status transition --- ## DELETE /v1/discord/guilds/{guildId}/events/{eventId} **Delete a Discord scheduled event** Hard-delete an event. Use PATCH with `status: 'cancelled'` instead if you want the event preserved in the guild's history. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event deleted. **Response Body:** - **success** `boolean`: No description - **deleted** `string`: The deleted event's snowflake. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Bot lacks MANAGE_EVENTS in the guild. --- # Related Schema Definitions ## DiscordScheduledEvent Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints. Fields below are the subset Zernio consumes — Discord may return more (e.g. creator, image hash) which we pass through verbatim. ### Properties - **id** `string`: Event snowflake ID - **guild_id** `string`: No description - **channel_id** `string`: Voice/stage channel ID; null for external events. - **creator_id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **scheduled_start_time** `string`: No description - **scheduled_end_time** `string`: Required for external events; optional for voice/stage. - **privacy_level** `integer`: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. - one of: 2 - **status** `integer`: 1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED - one of: 1, 2, 3, 4 - **entity_type** `integer`: 1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL - one of: 1, 2, 3 - **entity_id** `string`: No description - **entity_metadata** `object`: - **location** `string`: External event location string. - **user_count** `integer`: Number of members who RSVP'd. Only present when withUserCount=true on list. - **image** `string`: Cover image hash; build URL via cdn.discordapp.com. --- # Get Discord account settings API Reference Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information. ## GET /v1/accounts/{accountId}/discord-settings **Get Discord account settings** Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Discord account settings **Response Body:** - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description (example: "discord") - **username** `string`: Channel name - **displayName** `string`: Guild - #channel display name - **profilePicture** `string`: Guild icon URL - **channelId** `string`: Connected channel snowflake ID - **channelName** `string`: Channel name - **channelType** `string`: Channel type (0 = text, 5 = announcement, 15 = forum) - **guildId** `string`: Guild (server) snowflake ID - **webhookUsername** `string`: Custom webhook display name (null = default "Zernio") - **webhookAvatarUrl** `string`: Custom webhook avatar URL (null = default bot avatar) #### 400: Not a Discord account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PATCH /v1/accounts/{accountId}/discord-settings **Update Discord settings** 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 - **accountId** (required) in path: No description ### Request Body - **webhookUsername** `string`: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord". - **webhookAvatarUrl** `string`: Custom avatar URL. Empty string resets to default bot avatar. - **channelId** `string`: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel. ### Responses #### 200: Settings updated **Response Body:** - **message** `string`: No description (example: "Discord settings updated") - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **channelId** `string`: No description - **channelName** `string`: No description - **channelType** `string`: No description - **guildId** `string`: No description - **webhookUsername** `string`: No description - **webhookAvatarUrl** `string`: No description #### 400: Invalid request (no changes #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found --- --- # List Discord guild members API Reference Cursor-paginated list of guild members. Returns Discord's raw member objects so callers can build community-ops automation (e.g. "add role to all members joined in the last 7 days") on the actual platform shape. **Important:** this endpoint requires the privileged "Server Members Intent" enabled on the Discord app (Developer Portal → Bot tab → toggle "Server Members Intent" ON, then Save). Without it, Discord returns an empty array with no error. Verify the intent is enabled before relying on this endpoint. Pagination: pass `after` = the last `user.id` from the previous page. Omit on the first call. Response includes a `nextCursor` and `hasMore` flag so callers don't need to know Discord's pagination shape. ## GET /v1/discord/guilds/{guildId}/members **List Discord guild members** Cursor-paginated list of guild members. Returns Discord's raw member objects so callers can build community-ops automation (e.g. "add role to all members joined in the last 7 days") on the actual platform shape. **Important:** this endpoint requires the privileged "Server Members Intent" enabled on the Discord app (Developer Portal → Bot tab → toggle "Server Members Intent" ON, then Save). Without it, Discord returns an empty array with no error. Verify the intent is enabled before relying on this endpoint. Pagination: pass `after` = the last `user.id` from the previous page. Omit on the first call. Response includes a `nextCursor` and `hasMore` flag so callers don't need to know Discord's pagination shape. ### Parameters - **guildId** (required) in path: No description - **accountId** (required) in query: No description - **limit** (optional) in query: Page size (1-1000). - **after** (optional) in query: Snowflake of the last member from the previous page. ### Responses #### 200: List of guild members. **Response Body:** - **data** `array[object]`: - **user** `object`: - **id** `string`: User snowflake - **username** `string`: No description - **discriminator** `string`: No description - **avatar** `string`: No description - **global_name** `string`: User's display name (post-2023 Discord rebrand) - **nick** `string`: Guild-specific nickname - **roles** `array[string]`: Snowflake IDs of roles assigned to this member - **joined_at** `string` (date-time): No description - **premium_since** `string` (date-time): When the user started boosting the server - **pagination** `object`: - **nextCursor** `string`: Pass as `after` on the next call. Null when there are no more pages. - **hasMore** `boolean`: No description #### 400: Invalid query params. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. --- --- # List Discord guild roles API Reference Returns all roles in a Discord guild. Useful for building role-mention pickers, role-permission UIs, or finding the role ID before calling the role-assign endpoint. Roles are returned unordered — sort client-side by `position` if you need Discord's UI ordering. Caller must pass `accountId` of a Discord SocialAccount bound to this guild (route verifies team access + guild match). ## GET /v1/discord/guilds/{guildId}/roles **List Discord guild roles** Returns all roles in a Discord guild. Useful for building role-mention pickers, role-permission UIs, or finding the role ID before calling the role-assign endpoint. Roles are returned unordered — sort client-side by `position` if you need Discord's UI ordering. Caller must pass `accountId` of a Discord SocialAccount bound to this guild (route verifies team access + guild match). ### Parameters - **guildId** (required) in path: Discord guild snowflake ID - **accountId** (required) in query: SocialAccount _id of the Discord account bound to this guild ### Responses #### 200: List of guild roles. **Response Body:** - **data** `array[object]`: - **id** `string`: Role snowflake ID - **name** `string`: No description - **color** `integer`: Decimal color (0 = no color). Convert to hex via .toString(16). - **position** `integer`: Position in role hierarchy (higher = more authority) - **permissions** `string`: Permissions bitfield as a stringified integer - **managed** `boolean`: True for integration-managed roles (bot roles) - **mentionable** `boolean`: No description - **hoist** `boolean`: True if role is displayed separately in member list #### 400: Invalid accountId or guildId format. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found #### 502: Discord rejected the request (bot lacks View Channels permission in the guild). --- --- # List pinned messages in a Discord channel API Reference Returns the channel's pinned messages, sorted most-recently-pinned first. Discord caps a channel at 50 pinned messages and returns the full list unpaginated. Bot needs READ_MESSAGE_HISTORY in the channel (granted by default BOT_PERMISSIONS). ## GET /v1/discord/channels/{channelId}/pins **List pinned messages in a Discord channel** Returns the channel's pinned messages, sorted most-recently-pinned first. Discord caps a channel at 50 pinned messages and returns the full list unpaginated. Bot needs READ_MESSAGE_HISTORY in the channel (granted by default BOT_PERMISSIONS). ### Parameters - **channelId** (required) in path: Discord channel snowflake. - **accountId** (required) in query: SocialAccount _id of any Discord account in the same guild. ### Responses #### 200: Pinned messages. **Response Body:** - **data** `array[object]`: - **id** `string`: No description - **channel_id** `string`: No description - **content** `string`: No description - **timestamp** `string` (date-time): No description - **author** `object`: No description - **attachments** `array[object]`: Type: `object` - **embeds** `array[object]`: Type: `object` #### 400: Invalid channelId or accountId format. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not accessible. #### 502: Bot lacks access to the channel. --- --- # List Discord scheduled events API Reference Return all scheduled events in the guild. Events are distinct from messages — they appear in the server's Events panel and Discord auto-notifies interested members ahead of start time. Pass `withUserCount=true` to include `user_count` (number of members who RSVP'd) on each event. Useful for surfacing engagement. ## GET /v1/discord/guilds/{guildId}/events **List Discord scheduled events** Return all scheduled events in the guild. Events are distinct from messages — they appear in the server's Events panel and Discord auto-notifies interested members ahead of start time. Pass `withUserCount=true` to include `user_count` (number of members who RSVP'd) on each event. Useful for surfacing engagement. ### Parameters - **guildId** (required) in path: No description - **accountId** (required) in query: No description - **withUserCount** (optional) in query: Include user_count on each event. ### Responses #### 200: List of scheduled events. **Response Body:** - **data** `array[DiscordScheduledEvent]`: #### 400: Invalid params. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Bot lacks access to the guild's events. --- ## POST /v1/discord/guilds/{guildId}/events **Create a Discord scheduled event** Create a guild scheduled event. Three event types, selected via the discriminator on `entity.type`: - `external` — off-platform (Zoom, in-person, livestream). Requires both `location` and `endsAt`. Most common type for scheduler integrations. - `voice` — hosted in a Discord voice channel. Requires `channelId`. - `stage` — hosted in a Discord stage channel. Requires `channelId`. Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events PR) need a re-invite OR a server admin manually granting the permission — see route header for details. ### Parameters - **guildId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **name** (required) `string`: No description - **description** `string`: No description - **startsAt** (required) `string`: ISO 8601 start time. Must be in the future. - **entity**: Platform-specific settings (see schema definitions below) - **imageDataUri** `string`: Optional cover image as a base64 data URI. ### Responses #### 200: Event created. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 400: Validation error (missing required fields for the chosen entity type, malformed snowflake, past startsAt, etc.). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_EVENTS in the guild. --- # Related Schema Definitions ## DiscordScheduledEvent Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints. Fields below are the subset Zernio consumes — Discord may return more (e.g. creator, image hash) which we pass through verbatim. ### Properties - **id** `string`: Event snowflake ID - **guild_id** `string`: No description - **channel_id** `string`: Voice/stage channel ID; null for external events. - **creator_id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **scheduled_start_time** `string`: No description - **scheduled_end_time** `string`: Required for external events; optional for voice/stage. - **privacy_level** `integer`: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. - one of: 2 - **status** `integer`: 1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED - one of: 1, 2, 3, 4 - **entity_type** `integer`: 1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL - one of: 1, 2, 3 - **entity_id** `string`: No description - **entity_metadata** `object`: - **location** `string`: External event location string. - **user_count** `integer`: Number of members who RSVP'd. Only present when withUserCount=true on list. - **image** `string`: Cover image hash; build URL via cdn.discordapp.com. --- # Pin a Discord message API Reference Pin a specific message in a channel. Path shape mirrors Discord's own API (`PUT /channels/{cid}/pins/{mid}`). Idempotent — re-pinning an already-pinned message is a 204 no-op. Constraints: - Bot needs MANAGE_MESSAGES in the channel. - 50-pin cap per channel — hitting it returns 400 (Discord-side). Caller should unpin one first. ## PUT /v1/discord/channels/{channelId}/pins/{messageId} **Pin a Discord message** Pin a specific message in a channel. Path shape mirrors Discord's own API (`PUT /channels/{cid}/pins/{mid}`). Idempotent — re-pinning an already-pinned message is a 204 no-op. Constraints: - Bot needs MANAGE_MESSAGES in the channel. - 50-pin cap per channel — hitting it returns 400 (Discord-side). Caller should unpin one first. ### Parameters - **channelId** (required) in path: No description - **messageId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Message pinned (or was already pinned — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: message_pinned - **channelId** `string`: No description - **messageId** `string`: No description #### 400: Validation error or pin cap (50) reached. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_MESSAGES in the channel. --- ## DELETE /v1/discord/channels/{channelId}/pins/{messageId} **Unpin a Discord message** Unpin a message. Same MANAGE_MESSAGES permission requirement as pin. Idempotent — unpinning a non-pinned message is a 204 no-op. ### Parameters - **channelId** (required) in path: No description - **messageId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Message unpinned (or was not pinned — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: message_unpinned - **channelId** `string`: No description - **messageId** `string`: No description #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_MESSAGES in the channel. --- --- # Remove a role from a guild member API Reference Remove one role from one member. Idempotent — removing a role the member doesn't have returns 204 no-op. Same permission + hierarchy constraints as the PUT counterpart. ## PUT /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId} **Assign a role to a guild member** Assign one role to one member. Idempotent on Discord's side — re-running on a member who already has the role is a 204 no-op. Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`) for zero-translation mental mapping. Bot needs MANAGE_ROLES permission in the guild AND its highest role must be above the target role (Discord hierarchy rule). The `@everyone` role (where roleId == guildId) cannot be assigned. ### Parameters - **guildId** (required) in path: No description - **userId** (required) in path: Discord user snowflake to assign the role to. - **roleId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Role assigned (or already present — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: role_assigned - **guildId** `string`: No description - **userId** `string`: No description - **roleId** `string`: No description #### 400: Validation error (malformed snowflake) or @everyone manipulation attempt. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Discord rejected the request: bot lacks MANAGE_ROLES, or target role is above the bot's highest role. --- ## DELETE /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId} **Remove a role from a guild member** Remove one role from one member. Idempotent — removing a role the member doesn't have returns 204 no-op. Same permission + hierarchy constraints as the PUT counterpart. ### Parameters - **guildId** (required) in path: No description - **userId** (required) in path: No description - **roleId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Role removed (or was already absent — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: role_removed - **guildId** `string`: No description - **userId** `string`: No description - **roleId** `string`: No description #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not in this guild. #### 502: Discord rejected the request (permission or hierarchy issue). --- --- # Send a Discord Direct Message API Reference Send a 1:1 Direct Message from the bot to a Discord user (by snowflake ID). Supports the same payload shape as channel posts — content, embeds, media attachments, and TTS. Constraints (Discord platform limits): - The bot can only DM users it shares at least one guild with. - If the recipient has DMs disabled for non-friends, Discord returns 403 (surfaces as a 502 platform error). - `content` capped at 2,000 chars. - At least one of `content`, `embeds`, or `attachments` is required. - The recipient must be identified by Discord snowflake ID (not username). This is a dedicated endpoint rather than a `POST /v1/posts` variant because DMs are 1:1 operational messages (onboarding, billing reminders, support pings) with a different lifecycle than scheduled channel posts. DMs are not persisted to `Post` / `ExternalPost` and are always sent immediately. ## POST /v1/discord/dms **Send a Discord Direct Message** Send a 1:1 Direct Message from the bot to a Discord user (by snowflake ID). Supports the same payload shape as channel posts — content, embeds, media attachments, and TTS. Constraints (Discord platform limits): - The bot can only DM users it shares at least one guild with. - If the recipient has DMs disabled for non-friends, Discord returns 403 (surfaces as a 502 platform error). - `content` capped at 2,000 chars. - At least one of `content`, `embeds`, or `attachments` is required. - The recipient must be identified by Discord snowflake ID (not username). This is a dedicated endpoint rather than a `POST /v1/posts` variant because DMs are 1:1 operational messages (onboarding, billing reminders, support pings) with a different lifecycle than scheduled channel posts. DMs are not persisted to `Post` / `ExternalPost` and are always sent immediately. ### Request Body - **accountId** (required) `string`: SocialAccount _id of the connected Discord account the bot speaks as. Caller must own the account (directly or via team membership). - **userId** (required) `string`: Discord snowflake ID of the recipient (15-21 digits). - **content** `string`: Message text, up to 2,000 characters. - **embeds** `array`: Up to 10 Discord embeds. Same shape as channel-post embeds (title, description, color, fields, etc.). See DiscordPlatformData.embeds for the embed object schema. - **attachments** `array`: Up to 10 media attachments. Each is `{ type: image|video|gif|document, url, filename?, mimeType?, size? }`. - **tts** `boolean`: Send as text-to-speech message. ### Responses #### 200: DM sent successfully. **Response Body:** - **messageId** `string`: Discord message snowflake ID - **channelId** `string`: DM channel snowflake (Discord auto-creates one per recipient pair) - **url** `string`: Direct link to the message — uses Discord's @me path for DMs - **timestamp** `string` (date-time): No description - **recipient** `object`: - **userId** `string`: No description - **platform** `string`: No description (example: "discord") - **account** `object`: - **id** `string`: No description - **username** `string`: No description - **displayName** `string`: No description #### 400: Validation error (missing required fields, content > 2000 chars, malformed snowflake, or all of content/embeds/attachments missing). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found or not accessible to this user. #### 502: Discord rejected the message (most commonly: bot doesn't share a guild with the recipient, OR the recipient has DMs disabled). Error body contains Discord's response. --- --- # Unpin a Discord message API Reference Unpin a message. Same MANAGE_MESSAGES permission requirement as pin. Idempotent — unpinning a non-pinned message is a 204 no-op. ## PUT /v1/discord/channels/{channelId}/pins/{messageId} **Pin a Discord message** Pin a specific message in a channel. Path shape mirrors Discord's own API (`PUT /channels/{cid}/pins/{mid}`). Idempotent — re-pinning an already-pinned message is a 204 no-op. Constraints: - Bot needs MANAGE_MESSAGES in the channel. - 50-pin cap per channel — hitting it returns 400 (Discord-side). Caller should unpin one first. ### Parameters - **channelId** (required) in path: No description - **messageId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Message pinned (or was already pinned — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: message_pinned - **channelId** `string`: No description - **messageId** `string`: No description #### 400: Validation error or pin cap (50) reached. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_MESSAGES in the channel. --- ## DELETE /v1/discord/channels/{channelId}/pins/{messageId} **Unpin a Discord message** Unpin a message. Same MANAGE_MESSAGES permission requirement as pin. Idempotent — unpinning a non-pinned message is a 204 no-op. ### Parameters - **channelId** (required) in path: No description - **messageId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Message unpinned (or was not pinned — idempotent). **Response Body:** - **success** `boolean`: No description - **operation** `string`: No description - one of: message_unpinned - **channelId** `string`: No description - **messageId** `string`: No description #### 400: Validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found. #### 502: Bot lacks MANAGE_MESSAGES in the channel. --- --- # Update a Discord scheduled event API Reference Patch any subset of fields. Passing `status: 'cancelled'` is how you cancel an event — Discord doesn't have a dedicated cancel endpoint, it's a status transition. Most status transitions Discord enforces (you can't go SCHEDULED → COMPLETED directly). The common consumer case is SCHEDULED → CANCELED. ## GET /v1/discord/guilds/{guildId}/events/{eventId} **Get a Discord scheduled event** ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. --- ## PATCH /v1/discord/guilds/{guildId}/events/{eventId} **Update a Discord scheduled event** Patch any subset of fields. Passing `status: 'cancelled'` is how you cancel an event — Discord doesn't have a dedicated cancel endpoint, it's a status transition. Most status transitions Discord enforces (you can't go SCHEDULED → COMPLETED directly). The common consumer case is SCHEDULED → CANCELED. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description - **name** `string`: No description - **description** `string`: No description - **startsAt** `string`: No description - **endsAt** `string`: No description - **location** `string`: For external events. - **status** `string`: Status transition. Most common: 'cancelled' to cancel an event. - one of: scheduled, active, completed, cancelled - **imageDataUri** `string`: No description ### Responses #### 200: Event updated. **Response Body:** - **data**: `DiscordScheduledEvent` - See schema definition #### 400: Validation error, OR no updatable fields beyond accountId provided. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Discord rejected the update (invalid status transition --- ## DELETE /v1/discord/guilds/{guildId}/events/{eventId} **Delete a Discord scheduled event** Hard-delete an event. Use PATCH with `status: 'cancelled'` instead if you want the event preserved in the guild's history. ### Parameters - **guildId** (required) in path: No description - **eventId** (required) in path: No description - **accountId** (required) in query: No description ### Responses #### 200: Event deleted. **Response Body:** - **success** `boolean`: No description - **deleted** `string`: The deleted event's snowflake. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Event or Discord account not found. #### 502: Bot lacks MANAGE_EVENTS in the guild. --- # Related Schema Definitions ## DiscordScheduledEvent Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints. Fields below are the subset Zernio consumes — Discord may return more (e.g. creator, image hash) which we pass through verbatim. ### Properties - **id** `string`: Event snowflake ID - **guild_id** `string`: No description - **channel_id** `string`: Voice/stage channel ID; null for external events. - **creator_id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **scheduled_start_time** `string`: No description - **scheduled_end_time** `string`: Required for external events; optional for voice/stage. - **privacy_level** `integer`: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. - one of: 2 - **status** `integer`: 1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED - one of: 1, 2, 3, 4 - **entity_type** `integer`: 1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL - one of: 1, 2, 3 - **entity_id** `string`: No description - **entity_metadata** `object`: - **location** `string`: External event location string. - **user_count** `integer`: Number of members who RSVP'd. Only present when withUserCount=true on list. - **image** `string`: Cover image hash; build URL via cdn.discordapp.com. --- # Update Discord settings API Reference 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. ## GET /v1/accounts/{accountId}/discord-settings **Get Discord account settings** Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information. ### Parameters - **accountId** (required) in path: No description ### Responses #### 200: Discord account settings **Response Body:** - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description (example: "discord") - **username** `string`: Channel name - **displayName** `string`: Guild - #channel display name - **profilePicture** `string`: Guild icon URL - **channelId** `string`: Connected channel snowflake ID - **channelName** `string`: Channel name - **channelType** `string`: Channel type (0 = text, 5 = announcement, 15 = forum) - **guildId** `string`: Guild (server) snowflake ID - **webhookUsername** `string`: Custom webhook display name (null = default "Zernio") - **webhookAvatarUrl** `string`: Custom webhook avatar URL (null = default bot avatar) #### 400: Not a Discord account #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- ## PATCH /v1/accounts/{accountId}/discord-settings **Update Discord settings** 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 - **accountId** (required) in path: No description ### Request Body - **webhookUsername** `string`: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord". - **webhookAvatarUrl** `string`: Custom avatar URL. Empty string resets to default bot avatar. - **channelId** `string`: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel. ### Responses #### 200: Settings updated **Response Body:** - **message** `string`: No description (example: "Discord settings updated") - **account** `object`: - **_id** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **displayName** `string`: No description - **profilePicture** `string`: No description - **channelId** `string`: No description - **channelName** `string`: No description - **channelType** `string`: No description - **guildId** `string`: No description - **webhookUsername** `string`: No description - **webhookAvatarUrl** `string`: No description #### 400: Invalid request (no changes #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Discord account not found --- --- # Batch get reviews API Reference Fetches reviews across multiple locations in a single request. More efficient than calling GET /gmb-reviews per location for multi-location businesses. Returns a flat list of individual reviews, each tagged with its review resource name. Note: this endpoint does not return aggregate metrics (averageRating / totalReviewCount). For those, use the single-location GET /gmb-reviews endpoint. ## POST /v1/accounts/{accountId}/gmb-reviews/batch **Batch get reviews** Fetches reviews across multiple locations in a single request. More efficient than calling GET /gmb-reviews per location for multi-location businesses. Returns a flat list of individual reviews, each tagged with its review resource name. Note: this endpoint does not return aggregate metrics (averageRating / totalReviewCount). For those, use the single-location GET /gmb-reviews endpoint. ### Parameters - **accountId** (required) in path: No description ### Request Body - **locationNames** (required) `array`: Array of full location resource names (e.g. ['accounts/123/locations/456']) - **pageSize** `integer`: Number of reviews per page (max 50) - **pageToken** `string`: Pagination token from previous response ### Responses #### 200: Batch reviews fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationReviews** `array[object]`: - **name** `string`: Full review resource name (accounts/*/locations/*/reviews/*) - **review** `object`: The review object (reviewId - **nextPageToken** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Complete a verification API Reference Completes a PENDING verification by submitting the PIN/code Google sent the business (postcard code, SMS PIN, etc.). On success the verification moves to COMPLETED. ## POST /v1/accounts/{accountId}/gmb-verifications/{verificationId}/complete **Complete a verification** Completes a PENDING verification by submitting the PIN/code Google sent the business (postcard code, SMS PIN, etc.). On success the verification moves to COMPLETED. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **verificationId** (required) in path: The last segment of a verification `name` from GET /gmb-verifications. - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **pin** (required) `string`: The code Google sent to the business. ### Responses #### 200: Verification completed **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **verification** `object`: - **name** `string`: No description - **method** `string`: No description - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **state** `string`: No description - one of: PENDING, COMPLETED, FAILED - **createTime** `string` (date-time): No description #### 400: Invalid request (e.g. wrong PIN or verification not pending) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Upload photo API Reference 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. ## GET /v1/accounts/{accountId}/gmb-media **List media** Lists media items (photos) for a Google Business Profile location. Returns photo URLs, descriptions, categories, and metadata. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: Number of items to return (max 100) - **pageToken** (optional) in query: Pagination token from previous response ### Responses #### 200: Media items fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **mediaItems** `array[object]`: - **name** `string`: Resource name - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **sourceUrl** `string`: No description - **googleUrl** `string`: Google-hosted URL - **thumbnailUrl** `string`: No description - **description** `string`: No description - **createTime** `string` (date-time): No description - **locationAssociation** `object`: - **category** `string`: No description - **nextPageToken** `string`: No description - **totalMediaItemsCount** `integer`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-media **Upload photo** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **sourceUrl** (required) `string`: Publicly accessible image URL - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **description** `string`: Photo description - **category** `string`: Where the photo appears on the listing - one of: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL ### Responses #### 200: Media created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **mediaFormat** `string`: No description - **googleUrl** `string`: No description #### 400: Invalid request or unsupported media format **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-media **Delete photo** Deletes a photo or media item from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **mediaId** (required) in query: The media item ID to delete ### Responses #### 200: Media deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **mediaId** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Create action link API Reference Creates a place action link for a location. Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE. ## GET /v1/accounts/{accountId}/gmb-place-actions **List action links** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: No description - **pageToken** (optional) in query: No description ### Responses #### 200: Place actions fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **placeActionLinks** `array[object]`: - **name** `string`: Resource name - **uri** `string`: Action URL - **placeActionType** `string`: No description - **createTime** `string` (date-time): No description - **updateTime** `string` (date-time): No description - **nextPageToken** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-place-actions **Create action link** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **uri** (required) `string`: The action URL - **placeActionType** (required) `string`: Type of action - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: Resource name of the created link - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-place-actions **Delete action link** Deletes a place action link (e.g. booking or ordering URL) from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **name** (required) in query: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) ### Responses #### 200: Place action deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **name** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PATCH /v1/accounts/{accountId}/gmb-place-actions **Update action link** Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **name** (required) `string`: Resource name of the place action link (e.g. locations/123/placeActionLinks/456) - **uri** `string`: New action URL - **placeActionType** `string`: New action type - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action updated successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Delete photo API Reference Deletes a photo or media item from a GBP location. ## GET /v1/accounts/{accountId}/gmb-media **List media** Lists media items (photos) for a Google Business Profile location. Returns photo URLs, descriptions, categories, and metadata. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: Number of items to return (max 100) - **pageToken** (optional) in query: Pagination token from previous response ### Responses #### 200: Media items fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **mediaItems** `array[object]`: - **name** `string`: Resource name - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **sourceUrl** `string`: No description - **googleUrl** `string`: Google-hosted URL - **thumbnailUrl** `string`: No description - **description** `string`: No description - **createTime** `string` (date-time): No description - **locationAssociation** `object`: - **category** `string`: No description - **nextPageToken** `string`: No description - **totalMediaItemsCount** `integer`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-media **Upload photo** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **sourceUrl** (required) `string`: Publicly accessible image URL - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **description** `string`: Photo description - **category** `string`: Where the photo appears on the listing - one of: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL ### Responses #### 200: Media created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **mediaFormat** `string`: No description - **googleUrl** `string`: No description #### 400: Invalid request or unsupported media format **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-media **Delete photo** Deletes a photo or media item from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **mediaId** (required) in query: The media item ID to delete ### Responses #### 200: Media deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **mediaId** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Delete action link API Reference Deletes a place action link (e.g. booking or ordering URL) from a GBP location. ## GET /v1/accounts/{accountId}/gmb-place-actions **List action links** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: No description - **pageToken** (optional) in query: No description ### Responses #### 200: Place actions fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **placeActionLinks** `array[object]`: - **name** `string`: Resource name - **uri** `string`: Action URL - **placeActionType** `string`: No description - **createTime** `string` (date-time): No description - **updateTime** `string` (date-time): No description - **nextPageToken** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-place-actions **Create action link** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **uri** (required) `string`: The action URL - **placeActionType** (required) `string`: Type of action - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: Resource name of the created link - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-place-actions **Delete action link** Deletes a place action link (e.g. booking or ordering URL) from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **name** (required) in query: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) ### Responses #### 200: Place action deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **name** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PATCH /v1/accounts/{accountId}/gmb-place-actions **Update action link** Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **name** (required) `string`: Resource name of the place action link (e.g. locations/123/placeActionLinks/456) - **uri** `string`: New action URL - **placeActionType** `string`: New action type - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action updated successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Delete a review reply API Reference Removes the business owner reply from a Google Business review. The review itself remains. ## POST /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply **Reply to a review** Posts (or updates) the business owner reply to a Google Business review. The reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations). Calling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side). ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **reviewId** (required) in path: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name ### Request Body - **comment** (required) `string`: The reply text to post on the review. Must be non-empty. ### Responses #### 200: Reply posted successfully **Response Body:** - **success** `boolean`: No description - **reviewId** `string`: No description - **platform** `string`: No description (example: "googlebusiness") #### 400: Invalid request, missing comment, non-GBP account, or account missing location metadata **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid (account must be reconnected) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to post reply **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply **Delete a review reply** Removes the business owner reply from a Google Business review. The review itself remains. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **reviewId** (required) in path: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name ### Responses #### 200: Reply deleted successfully **Response Body:** - **success** `boolean`: No description - **message** `string`: No description - **platform** `string`: No description (example: "googlebusiness") #### 400: Invalid request, non-GBP account, or account missing location metadata **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid (account must be reconnected) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to delete reply **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Fetch verification options API Reference Reports the verification methods Google currently offers for the location. Non-mutating (nothing is sent to the business). `languageCode` is required; service-area ("CUSTOMER_LOCATION_ONLY") businesses also require `context.address`, otherwise Google returns 400. ## POST /v1/accounts/{accountId}/gmb-verifications/options **Fetch verification options** Reports the verification methods Google currently offers for the location. Non-mutating (nothing is sent to the business). `languageCode` is required; service-area ("CUSTOMER_LOCATION_ONLY") businesses also require `context.address`, otherwise Google returns 400. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. ### Request Body - **languageCode** (required) `string`: No description - **context** `object`: ServiceBusinessContext. Required for service-area businesses (must include the service address). ### Responses #### 200: Verification options fetched **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **options** `array[object]`: - **verificationMethod** `string`: No description - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **phoneNumber** `string`: Present for PHONE_CALL / SMS. #### 400: Invalid request (e.g. missing service business context, or missing languageCode) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get attribute metadata API Reference Returns metadata about which Google Business Profile attributes are available for a location or business category. Use this endpoint to discover valid attribute names, value types, and allowed enum values before reading or writing via gmb-attributes. Two mutually exclusive query modes: **Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`). Google returns attributes valid for that specific location. **Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`. Google returns attributes valid for that category across the given region. `languageCode` is optional in category mode. Both modes support `pageSize` and `pageToken` for pagination. ## GET /v1/accounts/{accountId}/gmb-attribute-metadata **Get attribute metadata** Returns metadata about which Google Business Profile attributes are available for a location or business category. Use this endpoint to discover valid attribute names, value types, and allowed enum values before reading or writing via gmb-attributes. Two mutually exclusive query modes: **Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`). Google returns attributes valid for that specific location. **Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`. Google returns attributes valid for that category across the given region. `languageCode` is optional in category mode. Both modes support `pageSize` and `pageToken` for pagination. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: GBP location ID (e.g. "6257659026299438786"). If omitted, uses the account's stored selectedLocationId. Mutually exclusive with categoryName. - **categoryName** (optional) in query: Category resource name, must start with "categories/" (e.g. "categories/gcid:plumber"). Required together with regionCode. Mutually exclusive with locationId. - **regionCode** (optional) in query: BCP-47 region code (e.g. "US", "ES"). Required when categoryName is provided. - **languageCode** (optional) in query: BCP-47 language code for display names (e.g. "en", "es"). Optional when categoryName is provided. Omitted from the Google call when not supplied. - **pageSize** (optional) in query: Maximum number of attribute metadata items to return. Google defaults to 200. - **pageToken** (optional) in query: Pagination token from a previous response's nextPageToken field. ### Responses #### 200: Attribute metadata fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: Only present in location mode. - **attributeMetadata** `array[object]`: - **parent** `string`: Resource name of the attribute (e.g. "attributes/has_delivery"). - **valueType** `string`: Value type (e.g. BOOL, ENUM, URL, REPEATED_ENUM). - **displayName** `string`: Localized human-readable attribute name. - **groupDisplayName** `string`: Display name of the attribute group. - **repeatable** `boolean`: True if multiple values can be set simultaneously. - **deprecated** `boolean`: True if this attribute should no longer be used. - **valueMetadata** `array[object]`: Possible enum values (for ENUM / REPEATED_ENUM types). - **value** `string`: No description - **displayName** `string`: No description - **nextPageToken** `string`: Present when additional pages of results are available. #### 400: Invalid request (mixed modes, missing required params, wrong platform, or Google returned 4xx) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Access token is invalid or revoked. Reconnect the Google Business Profile account. **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Account not found **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get attributes API Reference Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category. ## GET /v1/accounts/{accountId}/gmb-attributes **Get attributes** Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Attributes fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **attributes** `array[object]`: - **name** `string`: Attribute identifier (e.g. has_delivery) - **valueType** `string`: Value type (BOOL, ENUM, URL, REPEATED_ENUM) - **values** `array[items]`: - **repeatedEnumValue** `object`: - **setValues** `array[string]`: - **unsetValues** `array[string]`: #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-attributes **Update attributes** Updates location attributes (amenities, services, etc.). The attributeMask specifies which attributes to update (comma-separated). ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **attributes** (required) `array`: No description - **attributeMask** (required) `string`: Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout') ### Responses #### 200: Attributes updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **attributes** `array[object]`: Type: `object` #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get food menus API Reference Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support. ## GET /v1/accounts/{accountId}/gmb-food-menus **Get food menus** Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Food menus fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **name** `string`: Resource name of the food menus - **menus** `array[FoodMenu]`: #### 400: Invalid request - not a Google Business account or missing location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 403: Permission denied for this location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to fetch food menus **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-food-menus **Update food menus** Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **menus** (required) `array`: Array of food menus to set - **updateMask** `string`: Field mask for partial updates (e.g. "menus") ### Responses #### 200: Food menus updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **name** `string`: No description - **menus** `array[FoodMenu]`: #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 403: Permission denied for this location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to update food menus **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## FoodMenu ### Properties - **labels** (required) `array`: No description - **sections** `array`: No description - **cuisines** `array`: Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE) - **sourceUrl** `string`: URL of the original menu source ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get location details API Reference Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields. ## GET /v1/accounts/{accountId}/gmb-location-details **Get location details** Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **readMask** (optional) in query: 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. ### Responses #### 200: Location details fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **location** `object`: 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. - **name** `string`: Business name as set in GBP - **placeId** `string`: Google Maps Place ID for this location - **reviewUrl** `string`: Public "write a review" URL Google generates for this place - **mapsUri** `string`: Public Google Maps URL for this location - **isVerified** `boolean`: True when the location has Voice of Merchant (verified + live on Google) - **title** `string`: Business name - **regularHours** `object`: - **periods** `array[object]`: - **openDay** `string`: No description - one of: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY - **openTime** `string`: Opening time in HH:MM format - **closeDay** `string`: No description - **closeTime** `string`: No description - **specialHours** `object`: - **specialHourPeriods** `array[object]`: - **startDate** `object`: - **year** `integer`: No description - **month** `integer`: No description - **day** `integer`: No description - **endDate** `object`: - **year** `integer`: No description - **month** `integer`: No description - **day** `integer`: No description - **openTime** `string`: No description - **closeTime** `string`: No description - **closed** `boolean`: No description - **profile** `object`: - **description** `string`: Business description - **websiteUri** `string`: No description - **phoneNumbers** `object`: - **primaryPhone** `string`: No description - **additionalPhones** `array[string]`: - **categories** `object`: Business categories (returned when readMask includes 'categories') - **primaryCategory** `object`: - **name** `string`: Category resource name - **displayName** `string`: Human-readable category name - **additionalCategories** `array[object]`: - **name** `string`: No description - **displayName** `string`: No description - **serviceItems** `array[object]`: Services offered (returned when readMask includes 'serviceItems') - **structuredServiceItem** `object`: - **serviceTypeId** `string`: No description - **description** `string`: No description - **freeFormServiceItem** `object`: - **category** `string`: No description - **label** `object`: - **displayName** `string`: No description - **languageCode** `string`: No description - **price** `object`: - **currencyCode** `string`: No description - **units** `string`: No description - **nanos** `integer`: No description #### 400: 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). **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/accounts/{accountId}/gmb-location-details **Update location details** 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 - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **updateMask** (required) `string`: 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** `object`: No description - **specialHours** `object`: No description - **profile** `object`: No description - **websiteUri** `string`: No description - **phoneNumbers** `object`: No description - **categories** `object`: Primary and additional business categories. Use updateMask='categories' to update. - **serviceItems** `array`: Services offered by the business. Use updateMask='serviceItems' to update. ### Responses #### 200: Location updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description #### 400: Invalid request or missing updateMask **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get reviews API Reference Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination. ## GET /v1/accounts/{accountId}/gmb-reviews **Get reviews** Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: Number of reviews to fetch per page (max 50) - **pageToken** (optional) in query: Pagination token from previous response ### Responses #### 200: Reviews fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **reviews** `array[object]`: - **id** `string`: Review ID - **name** `string`: Full resource name - **reviewer** `object`: - **displayName** `string`: No description - **profilePhotoUrl** `string`: No description - **isAnonymous** `boolean`: No description - **rating** `integer`: Numeric star rating - **starRating** `string`: Google's string rating - one of: ONE, TWO, THREE, FOUR, FIVE - **comment** `string`: Review text - **createTime** `string` (date-time): No description - **updateTime** `string` (date-time): No description - **reviewReply** `object`: - **comment** `string`: Business owner reply - **updateTime** `string` (date-time): No description - **averageRating** `number`: Overall average rating - **totalReviewCount** `integer`: Total number of reviews - **nextPageToken** `string`: Token for next page #### 400: Invalid request - not a Google Business account or missing location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 403: Permission denied for this location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to fetch reviews **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get services API Reference Gets the services offered by a Google Business Profile location. Returns an array of service items (structured or free-form with optional price). ## GET /v1/accounts/{accountId}/gmb-services **Get services** Gets the services offered by a Google Business Profile location. Returns an array of service items (structured or free-form with optional price). ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. ### Responses #### 200: Services fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **services** `array[object]`: - **structuredServiceItem** `object`: - **serviceTypeId** `string`: No description - **description** `string`: No description - **freeFormServiceItem** `object`: - **category** `string`: No description - **label** `object`: - **displayName** `string`: No description - **description** `string`: No description - **price** `object`: - **currencyCode** `string`: No description (example: "USD") - **units** `string`: No description (example: "50") - **nanos** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-services **Replace services** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **serviceItems** (required) `array`: No description ### Responses #### 200: Services updated successfully **Response Body:** - **success** `boolean`: No description - **services** `array[object]`: Type: `object` #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get verification state API Reference Returns the location's Voice of Merchant state plus its verification history. `voiceOfMerchantState.hasVoiceOfMerchant` tells you whether the listing is verified and published; when it is false, `verify` reports whether a verification is already pending. Each entry in `verifications` has a `state` of PENDING, COMPLETED, or FAILED. ## GET /v1/accounts/{accountId}/gmb-verifications **Get verification state** Returns the location's Voice of Merchant state plus its verification history. `voiceOfMerchantState.hasVoiceOfMerchant` tells you whether the listing is verified and published; when it is false, `verify` reports whether a verification is already pending. Each entry in `verifications` has a `state` of PENDING, COMPLETED, or FAILED. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Verification state fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **voiceOfMerchantState** `object`: Raw Voice of Merchant state from Google. - **hasVoiceOfMerchant** `boolean`: True when the listing is verified and published (eligible to surface reviews - **hasBusinessAuthority** `boolean`: True when the authenticated user has owner/manager authority over the listing. - **verify** `object`: Present when verification is the path to Voice of Merchant. - **hasPendingVerification** `boolean`: True when a verification is already in progress. - **verifications** `array[object]`: Verification history, newest first. Empty when none exist. - **name** `string`: Resource name, e.g. "locations/123/verifications/0T1776879124712". The last segment is the verificationId. - **method** `string`: Method used (omitted on some entries). - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **state** `string`: No description - one of: PENDING, COMPLETED, FAILED - **createTime** `string` (date-time): No description #### 400: Not a Google Business account or missing location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## POST /v1/accounts/{accountId}/gmb-verifications **Start a verification** Starts a verification for the location. This is a mutating action: depending on `method`, Google mails a postcard, places a call, or sends an SMS/email to the business. Submit the resulting code with POST /gmb-verifications/{verificationId}/complete. Use POST /gmb-verifications/options first to discover which methods are eligible. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **method** (required) `string`: The verification method. Selects which method-specific field below is required. - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **languageCode** `string`: No description - **phoneNumber** `string`: For PHONE_CALL / SMS. - **emailAddress** `string`: For EMAIL. - **mailerContact** `object`: For ADDRESS (postcard) verification. - **context** `object`: ServiceBusinessContext (e.g. service address). Required for service-area businesses. ### Responses #### 200: Verification started **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **verification** `object`: - **name** `string`: No description - **method** `string`: No description - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **state** `string`: No description - one of: PENDING, COMPLETED, FAILED - **createTime** `string` (date-time): No description #### 400: Invalid request (e.g. wrong field for the chosen method, or Google rejected it) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # List media API Reference Lists media items (photos) for a Google Business Profile location. Returns photo URLs, descriptions, categories, and metadata. ## GET /v1/accounts/{accountId}/gmb-media **List media** Lists media items (photos) for a Google Business Profile location. Returns photo URLs, descriptions, categories, and metadata. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: Number of items to return (max 100) - **pageToken** (optional) in query: Pagination token from previous response ### Responses #### 200: Media items fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **mediaItems** `array[object]`: - **name** `string`: Resource name - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **sourceUrl** `string`: No description - **googleUrl** `string`: Google-hosted URL - **thumbnailUrl** `string`: No description - **description** `string`: No description - **createTime** `string` (date-time): No description - **locationAssociation** `object`: - **category** `string`: No description - **nextPageToken** `string`: No description - **totalMediaItemsCount** `integer`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-media **Upload photo** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **sourceUrl** (required) `string`: Publicly accessible image URL - **mediaFormat** `string`: No description - one of: PHOTO, VIDEO - **description** `string`: Photo description - **category** `string`: Where the photo appears on the listing - one of: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL ### Responses #### 200: Media created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **mediaFormat** `string`: No description - **googleUrl** `string`: No description #### 400: Invalid request or unsupported media format **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-media **Delete photo** Deletes a photo or media item from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **mediaId** (required) in query: The media item ID to delete ### Responses #### 200: Media deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **mediaId** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # List action links API Reference Lists place action links for a Google Business Profile location. Place actions are the booking, ordering, and reservation buttons that appear on your listing. ## GET /v1/accounts/{accountId}/gmb-place-actions **List action links** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: No description - **pageToken** (optional) in query: No description ### Responses #### 200: Place actions fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **placeActionLinks** `array[object]`: - **name** `string`: Resource name - **uri** `string`: Action URL - **placeActionType** `string`: No description - **createTime** `string` (date-time): No description - **updateTime** `string` (date-time): No description - **nextPageToken** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-place-actions **Create action link** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **uri** (required) `string`: The action URL - **placeActionType** (required) `string`: Type of action - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: Resource name of the created link - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-place-actions **Delete action link** Deletes a place action link (e.g. booking or ordering URL) from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **name** (required) in query: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) ### Responses #### 200: Place action deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **name** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PATCH /v1/accounts/{accountId}/gmb-place-actions **Update action link** Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **name** (required) `string`: Resource name of the place action link (e.g. locations/123/placeActionLinks/456) - **uri** `string`: New action URL - **placeActionType** `string`: New action type - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action updated successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Reply to a review API Reference Posts (or updates) the business owner reply to a Google Business review. The reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations). Calling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side). ## POST /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply **Reply to a review** Posts (or updates) the business owner reply to a Google Business review. The reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations). Calling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side). ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **reviewId** (required) in path: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name ### Request Body - **comment** (required) `string`: The reply text to post on the review. Must be non-empty. ### Responses #### 200: Reply posted successfully **Response Body:** - **success** `boolean`: No description - **reviewId** `string`: No description - **platform** `string`: No description (example: "googlebusiness") #### 400: Invalid request, missing comment, non-GBP account, or account missing location metadata **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid (account must be reconnected) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to post reply **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply **Delete a review reply** Removes the business owner reply from a Google Business review. The review itself remains. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **reviewId** (required) in path: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name ### Responses #### 200: Reply deleted successfully **Response Body:** - **success** `boolean`: No description - **message** `string`: No description - **platform** `string`: No description (example: "googlebusiness") #### 400: Invalid request, non-GBP account, or account missing location metadata **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid (account must be reconnected) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to delete reply **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Start a verification API Reference Starts a verification for the location. This is a mutating action: depending on `method`, Google mails a postcard, places a call, or sends an SMS/email to the business. Submit the resulting code with POST /gmb-verifications/{verificationId}/complete. Use POST /gmb-verifications/options first to discover which methods are eligible. ## GET /v1/accounts/{accountId}/gmb-verifications **Get verification state** Returns the location's Voice of Merchant state plus its verification history. `voiceOfMerchantState.hasVoiceOfMerchant` tells you whether the listing is verified and published; when it is false, `verify` reports whether a verification is already pending. Each entry in `verifications` has a `state` of PENDING, COMPLETED, or FAILED. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Verification state fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **voiceOfMerchantState** `object`: Raw Voice of Merchant state from Google. - **hasVoiceOfMerchant** `boolean`: True when the listing is verified and published (eligible to surface reviews - **hasBusinessAuthority** `boolean`: True when the authenticated user has owner/manager authority over the listing. - **verify** `object`: Present when verification is the path to Voice of Merchant. - **hasPendingVerification** `boolean`: True when a verification is already in progress. - **verifications** `array[object]`: Verification history, newest first. Empty when none exist. - **name** `string`: Resource name, e.g. "locations/123/verifications/0T1776879124712". The last segment is the verificationId. - **method** `string`: Method used (omitted on some entries). - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **state** `string`: No description - one of: PENDING, COMPLETED, FAILED - **createTime** `string` (date-time): No description #### 400: Not a Google Business account or missing location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## POST /v1/accounts/{accountId}/gmb-verifications **Start a verification** Starts a verification for the location. This is a mutating action: depending on `method`, Google mails a postcard, places a call, or sends an SMS/email to the business. Submit the resulting code with POST /gmb-verifications/{verificationId}/complete. Use POST /gmb-verifications/options first to discover which methods are eligible. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **method** (required) `string`: The verification method. Selects which method-specific field below is required. - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **languageCode** `string`: No description - **phoneNumber** `string`: For PHONE_CALL / SMS. - **emailAddress** `string`: For EMAIL. - **mailerContact** `object`: For ADDRESS (postcard) verification. - **context** `object`: ServiceBusinessContext (e.g. service address). Required for service-area businesses. ### Responses #### 200: Verification started **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **verification** `object`: - **name** `string`: No description - **method** `string`: No description - one of: ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER - **state** `string`: No description - one of: PENDING, COMPLETED, FAILED - **createTime** `string` (date-time): No description #### 400: Invalid request (e.g. wrong field for the chosen method, or Google rejected it) **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Update attributes API Reference Updates location attributes (amenities, services, etc.). The attributeMask specifies which attributes to update (comma-separated). ## GET /v1/accounts/{accountId}/gmb-attributes **Get attributes** Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Attributes fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **attributes** `array[object]`: - **name** `string`: Attribute identifier (e.g. has_delivery) - **valueType** `string`: Value type (BOOL, ENUM, URL, REPEATED_ENUM) - **values** `array[items]`: - **repeatedEnumValue** `object`: - **setValues** `array[string]`: - **unsetValues** `array[string]`: #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-attributes **Update attributes** Updates location attributes (amenities, services, etc.). The attributeMask specifies which attributes to update (comma-separated). ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **attributes** (required) `array`: No description - **attributeMask** (required) `string`: Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout') ### Responses #### 200: Attributes updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **attributes** `array[object]`: Type: `object` #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Update food menus API Reference Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates. ## GET /v1/accounts/{accountId}/gmb-food-menus **Get food menus** Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Responses #### 200: Food menus fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **name** `string`: Resource name of the food menus - **menus** `array[FoodMenu]`: #### 400: Invalid request - not a Google Business account or missing location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token invalid **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 403: Permission denied for this location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to fetch food menus **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-food-menus **Update food menus** Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **menus** (required) `array`: Array of food menus to set - **updateMask** `string`: Field mask for partial updates (e.g. "menus") ### Responses #### 200: Food menus updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **name** `string`: No description - **menus** `array[FoodMenu]`: #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 403: Permission denied for this location **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Failed to update food menus **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## FoodMenu ### Properties - **labels** (required) `array`: No description - **sections** `array`: No description - **cuisines** `array`: Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE) - **sourceUrl** `string`: URL of the original menu source ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Update location details API Reference 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. ## GET /v1/accounts/{accountId}/gmb-location-details **Get location details** Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields. ### Parameters - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **readMask** (optional) in query: 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. ### Responses #### 200: Location details fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **location** `object`: 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. - **name** `string`: Business name as set in GBP - **placeId** `string`: Google Maps Place ID for this location - **reviewUrl** `string`: Public "write a review" URL Google generates for this place - **mapsUri** `string`: Public Google Maps URL for this location - **isVerified** `boolean`: True when the location has Voice of Merchant (verified + live on Google) - **title** `string`: Business name - **regularHours** `object`: - **periods** `array[object]`: - **openDay** `string`: No description - one of: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY - **openTime** `string`: Opening time in HH:MM format - **closeDay** `string`: No description - **closeTime** `string`: No description - **specialHours** `object`: - **specialHourPeriods** `array[object]`: - **startDate** `object`: - **year** `integer`: No description - **month** `integer`: No description - **day** `integer`: No description - **endDate** `object`: - **year** `integer`: No description - **month** `integer`: No description - **day** `integer`: No description - **openTime** `string`: No description - **closeTime** `string`: No description - **closed** `boolean`: No description - **profile** `object`: - **description** `string`: Business description - **websiteUri** `string`: No description - **phoneNumbers** `object`: - **primaryPhone** `string`: No description - **additionalPhones** `array[string]`: - **categories** `object`: Business categories (returned when readMask includes 'categories') - **primaryCategory** `object`: - **name** `string`: Category resource name - **displayName** `string`: Human-readable category name - **additionalCategories** `array[object]`: - **name** `string`: No description - **displayName** `string`: No description - **serviceItems** `array[object]`: Services offered (returned when readMask includes 'serviceItems') - **structuredServiceItem** `object`: - **serviceTypeId** `string`: No description - **description** `string`: No description - **freeFormServiceItem** `object`: - **category** `string`: No description - **label** `object`: - **displayName** `string`: No description - **languageCode** `string`: No description - **price** `object`: - **currencyCode** `string`: No description - **units** `string`: No description - **nanos** `integer`: No description #### 400: 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). **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/accounts/{accountId}/gmb-location-details **Update location details** 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 - **accountId** (required) in path: The Zernio account ID (from /v1/accounts) - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **updateMask** (required) `string`: 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** `object`: No description - **specialHours** `object`: No description - **profile** `object`: No description - **websiteUri** `string`: No description - **phoneNumbers** `object`: No description - **categories** `object`: Primary and additional business categories. Use updateMask='categories' to update. - **serviceItems** `array`: Services offered by the business. Use updateMask='serviceItems' to update. ### Responses #### 200: Location updated successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description #### 400: Invalid request or missing updateMask **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized or token expired **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Update action link API Reference Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. ## GET /v1/accounts/{accountId}/gmb-place-actions **List action links** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **pageSize** (optional) in query: No description - **pageToken** (optional) in query: No description ### Responses #### 200: Place actions fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **placeActionLinks** `array[object]`: - **name** `string`: Resource name - **uri** `string`: Action URL - **placeActionType** `string`: No description - **createTime** `string` (date-time): No description - **updateTime** `string` (date-time): No description - **nextPageToken** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## POST /v1/accounts/{accountId}/gmb-place-actions **Create action link** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. ### Request Body - **uri** (required) `string`: The action URL - **placeActionType** (required) `string`: Type of action - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action created successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: Resource name of the created link - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## DELETE /v1/accounts/{accountId}/gmb-place-actions **Delete action link** Deletes a place action link (e.g. booking or ordering URL) from a GBP location. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - **name** (required) in query: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) ### Responses #### 200: Place action deleted successfully **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description - **name** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PATCH /v1/accounts/{accountId}/gmb-place-actions **Update action link** Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **name** (required) `string`: Resource name of the place action link (e.g. locations/123/placeActionLinks/456) - **uri** `string`: New action URL - **placeActionType** `string`: New action type - one of: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE ### Responses #### 200: Place action updated successfully **Response Body:** - **success** `boolean`: No description - **name** `string`: No description - **uri** `string`: No description - **placeActionType** `string`: No description #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Replace services API Reference 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). ## GET /v1/accounts/{accountId}/gmb-services **Get services** Gets the services offered by a Google Business Profile location. Returns an array of service items (structured or free-form with optional price). ### Parameters - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to query. If omitted, uses the account's selected location. ### Responses #### 200: Services fetched successfully **Response Body:** - **success** `boolean`: No description - **accountId** `string`: No description - **locationId** `string`: No description - **services** `array[object]`: - **structuredServiceItem** `object`: - **serviceTypeId** `string`: No description - **description** `string`: No description - **freeFormServiceItem** `object`: - **category** `string`: No description - **label** `object`: - **displayName** `string`: No description - **description** `string`: No description - **price** `object`: - **currencyCode** `string`: No description (example: "USD") - **units** `string`: No description (example: "50") - **nanos** `integer`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- ## PUT /v1/accounts/{accountId}/gmb-services **Replace services** 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 - **accountId** (required) in path: No description - **locationId** (optional) in query: Override which location to target. If omitted, uses the account's selected location. ### Request Body - **serviceItems** (required) `array`: No description ### Responses #### 200: Services updated successfully **Response Body:** - **success** `boolean`: No description - **services** `array[object]`: Type: `object` #### 400: Invalid request **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get analytics for a single conversation API Reference Per-conversation inbox analytics. The inbox analog of /v1/analytics/post-timeline — one conversation, daily totals, source mix. The {conversationId} path param accepts EITHER the Mongo `_id` of the Conversation document OR its `platformConversationId` (the same identity used by metadata.conversationId at ingest time). Ownership is verified in MongoDB against the caller's team before the Tinybird query fires. Max date range is 365 days. ## GET /v1/analytics/inbox/conversations/{conversationId} **Get analytics for a single conversation** Per-conversation inbox analytics. The inbox analog of /v1/analytics/post-timeline — one conversation, daily totals, source mix. The {conversationId} path param accepts EITHER the Mongo `_id` of the Conversation document OR its `platformConversationId` (the same identity used by metadata.conversationId at ingest time). Ownership is verified in MongoDB against the caller's team before the Tinybird query fires. Max date range is 365 days. ### Parameters - **conversationId** (required) in path: Mongo _id or platformConversationId. - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description ### Responses #### 200: Per-conversation analytics **Response Body:** - **success** `boolean`: No description - **conversationId** `string`: The platformConversationId - **mongoId** `string`: No description - **platform** `string`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **summary** `object`: - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description - **totalMessages** `integer`: No description - **firstMessageAt** `string` (date-time): No description - **lastMessageAt** `string` (date-time): No description - **timeseries** `array[object]`: - **date** `string` (date): No description - **sent** `integer`: No description - **received** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description - **bySource** `array[object]`: - **source** `string`: (unspecified) for legacy rows with no metadata.source - **count** `integer`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Conversation not found or not owned by the caller's team **Response Body:** - **error** `string`: No description (example: "Conversation not found.") - **code** `string`: No description (example: "conversation_not_found") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get inbox day-of-week × hour-of-day heatmap API Reference Day-of-week × hour-of-day breakdown of inbox messages. Buckets are sparse — only cells with at least one event are returned; clients zero-fill the rest to render the full 7×24 grid. The `dow` field follows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 = Sunday). Max date range is 365 days. ## GET /v1/analytics/inbox/heatmap **Get inbox day-of-week × hour-of-day heatmap** Day-of-week × hour-of-day breakdown of inbox messages. Buckets are sparse — only cells with at least one event are returned; clients zero-fill the rest to render the full 7×24 grid. The `dow` field follows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 = Sunday). Max date range is 365 days. ### Parameters - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **accountId** (optional) in query: No description - **source** (optional) in query: No description - **action** (optional) in query: Narrow to a single event type. "all" or omitted means no filter. ### Responses #### 200: Heatmap buckets **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **buckets** `array[object]`: - **dow** `integer`: 1 = Monday, 7 = Sunday - **hour** `integer`: No description - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get inbox response-time stats API Reference Time-to-first-response stats. Pairs each received message with the next sent message in the same conversation and reports the delta as both summary statistics and a fixed-bucket histogram suited for the analytics page's TTR chart. `sampleSize` reflects only conversations that received AND got a reply in the window — received-but-never-answered conversations are excluded. Compare against /v1/analytics/inbox/volume's `summary.received` to compute reply rate. Max date range is 365 days. ## GET /v1/analytics/inbox/response-time **Get inbox response-time stats** Time-to-first-response stats. Pairs each received message with the next sent message in the same conversation and reports the delta as both summary statistics and a fixed-bucket histogram suited for the analytics page's TTR chart. `sampleSize` reflects only conversations that received AND got a reply in the window — received-but-never-answered conversations are excluded. Compare against /v1/analytics/inbox/volume's `summary.received` to compute reply rate. Max date range is 365 days. ### Parameters - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **accountId** (optional) in query: No description ### Responses #### 200: Response-time summary + histogram **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **summary** `object`: - **sampleSize** `integer`: No description - **medianSeconds** `integer`: No description - **p90Seconds** `integer`: No description - **p99Seconds** `integer`: No description - **meanSeconds** `integer`: No description - **fastestSeconds** `integer`: No description - **slowestSeconds** `integer`: No description - **histogram** `array[object]`: - **bucket** `string`: Human label (0-1m, 1-5m, 5-15m, 15-60m, 1-4h, 4-24h, 1d+) - **lowerSeconds** `integer`: No description - **upperSeconds** `integer`: null on the open-ended last bucket - **count** `integer`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get inbox source breakdown API Reference Breakdown of inbox messages by their lineage source (the `metadata.source` field set at ingest time: human / workflow / sequence / broadcast / comment_automation / api / contact / platform). Each source row also carries a per-platform sub-split. Max date range is 365 days. ## GET /v1/analytics/inbox/source-breakdown **Get inbox source breakdown** Breakdown of inbox messages by their lineage source (the `metadata.source` field set at ingest time: human / workflow / sequence / broadcast / comment_automation / api / contact / platform). Each source row also carries a per-platform sub-split. Max date range is 365 days. ### Parameters - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **accountId** (optional) in query: No description ### Responses #### 200: Source breakdown **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **sources** `array[object]`: - **source** `string`: No description - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description - **byPlatform** `array[object]`: - **platform** `string`: No description - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get top accounts by inbox volume API Reference Leaderboard of social accounts by inbox message volume. Decorates each row with display labels from the live SocialAccount record (so the UI shows username + displayName, not just an ID). Accounts that no longer map to a SocialAccount surface as "(disconnected)" so the row stays visible. Max date range is 365 days. ## GET /v1/analytics/inbox/top-accounts **Get top accounts by inbox volume** Leaderboard of social accounts by inbox message volume. Decorates each row with display labels from the live SocialAccount record (so the UI shows username + displayName, not just an ID). Accounts that no longer map to a SocialAccount surface as "(disconnected)" so the row stays visible. Max date range is 365 days. ### Parameters - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **source** (optional) in query: No description - **limit** (optional) in query: Cap on returned rows. Lower than the posting listing's 100 because each row triggers a SocialAccount Mongo lookup. ### Responses #### 200: Top accounts leaderboard **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **accounts** `array[object]`: - **accountId** `string`: No description - **platform** `string`: No description - **displayName** `string`: (disconnected) when the SocialAccount no longer exists - **username** `string`: No description - **received** `integer`: No description - **sent** `integer`: No description - **total** `integer`: No description - **conversations** `integer`: No description - **medianResponseSeconds** `integer`: No description - **repliedCount** `integer`: Distinguishes 'instant replies' from 'no replies at all' so a zero medianResponseSeconds with repliedCount=0 renders as '—' instead of '0s' #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get inbox messaging volume API Reference Daily inbox messaging volume + breakdowns. Folds the raw messaging events into three projections so the client can render the volume chart, KPI strip, and per-platform stacked bar from a single call. Max date range is 365 days. ## GET /v1/analytics/inbox/volume **Get inbox messaging volume** Daily inbox messaging volume + breakdowns. Folds the raw messaging events into three projections so the client can render the volume chart, KPI strip, and per-platform stacked bar from a single call. Max date range is 365 days. ### Parameters - **fromDate** (required) in query: Inclusive lower bound (YYYY-MM-DD). Required. - **toDate** (optional) in query: Inclusive upper bound (YYYY-MM-DD). Defaults to today. - **profileId** (optional) in query: No description - **platform** (optional) in query: Filter by single platform (facebook, instagram, twitter, etc.). - **accountId** (optional) in query: No description - **source** (optional) in query: Filter by metadata.source lineage (human, workflow, sequence, broadcast, comment_automation, api, contact, platform). ### Responses #### 200: Volume breakdown **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **summary** `object`: - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description - **uniqueConversations** `integer`: No description - **timeseries** `array[object]`: - **date** `string` (date): No description - **sent** `integer`: No description - **received** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description - **byPlatform** `array[object]`: - **platform** `string`: No description - **sent** `integer`: No description - **received** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # List conversations with inbox analytics API Reference Per-conversation listing with per-row totals + first/last message timestamps. The inbox analog of GET /v1/analytics (posts listing) — same filter shape, same pagination, same sort/order semantics. Use as the entry point for the per-conversation analytics drawer at /v1/analytics/inbox/conversations/{conversationId}. Rows are enriched with the conversation's participant info (`participantName`, `participantUsername`, `participantPicture`) and last-message preview by joining the Conversation document scoped to the caller's team. Max date range is 365 days. ## GET /v1/analytics/inbox/conversations **List conversations with inbox analytics** Per-conversation listing with per-row totals + first/last message timestamps. The inbox analog of GET /v1/analytics (posts listing) — same filter shape, same pagination, same sort/order semantics. Use as the entry point for the per-conversation analytics drawer at /v1/analytics/inbox/conversations/{conversationId}. Rows are enriched with the conversation's participant info (`participantName`, `participantUsername`, `participantPicture`) and last-message preview by joining the Conversation document scoped to the caller's team. Max date range is 365 days. ### Parameters - **fromDate** (required) in query: No description - **toDate** (optional) in query: No description - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **accountId** (optional) in query: No description - **source** (optional) in query: No description - **limit** (optional) in query: No description - **page** (optional) in query: No description - **sortBy** (optional) in query: No description - **order** (optional) in query: No description ### Responses #### 200: Paginated conversation analytics list **Response Body:** - **success** `boolean`: No description - **from** `string` (date): No description - **to** `string` (date): No description - **items** `array[object]`: - **conversationId** `string`: The platformConversationId (the same identity used by metadata.conversationId) - **mongoId** `string`: The Conversation document _id, when a matching doc exists - **accountId** `string`: No description - **platform** `string`: No description - **participantName** `string`: No description - **participantUsername** `string`: No description - **participantPicture** `string`: No description - **lastMessage** `string`: Cached preview from the Conversation doc - **totalMessages** `integer`: No description - **received** `integer`: No description - **sent** `integer`: No description - **read** `integer`: No description - **failed** `integer`: No description - **firstMessageAt** `string` (date-time): No description - **lastMessageAt** `string` (date-time): No description - **pagination** `object`: - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **totalPages** `integer`: No description - **hasMore** `boolean`: No description #### 400: Validation error **Response Body:** - **error** `string`: No description - **details** `object`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 500: Internal server error **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## ErrorResponse ### Properties - **error** `string`: No description - **details** `object`: No description --- # Get Instagram story insights API Reference Returns metrics for a single story. The `source` field discriminates between three states: - `live` — fetched from Meta in real time (story is still active) - `cached` — fetched from a persisted `story_insights` webhook payload (story has expired but we received its final-state metrics from Meta) - `unavailable` — story has expired and we never received its webhook payload (for example, the account connected after the story expired) Field semantics follow Meta's API. Counts below 5 may be returned as 0 due to Meta's privacy floor on small audiences. The `navigation` field is the sum of `tapsForward + tapsBack + exits + swipesForward`. ## GET /v1/accounts/{accountId}/instagram/stories/{storyId}/insights **Get Instagram story insights** Returns metrics for a single story. The `source` field discriminates between three states: - `live` — fetched from Meta in real time (story is still active) - `cached` — fetched from a persisted `story_insights` webhook payload (story has expired but we received its final-state metrics from Meta) - `unavailable` — story has expired and we never received its webhook payload (for example, the account connected after the story expired) Field semantics follow Meta's API. Counts below 5 may be returned as 0 due to Meta's privacy floor on small audiences. The `navigation` field is the sum of `tapsForward + tapsBack + exits + swipesForward`. ### Parameters - **accountId** (required) in path: The Instagram account ID - **storyId** (required) in path: The Instagram media ID of the story. ### Responses #### 200: Story insights **Response Body:** - **data** (required) `object`: - **source** (required) `string`: No description - one of: live, cached, unavailable - **metrics** (required) `object`: - **views** (required) `integer`: Total story plays. Replaces deprecated 'impressions' for media created after 2024-07-02. - **reach** (required) `integer`: Unique accounts that saw the story. - **replies** (required) `integer`: DMs sent in reply to the story. - **shares** (required) `integer`: No description - **navigation** (required) `integer`: Total nav actions (tapsForward + tapsBack + exits + swipesForward). - **tapsForward** (required) `integer`: Tapped right to next slide of SAME story. - **tapsBack** (required) `integer`: Tapped left to previous slide. - **exits** (required) `integer`: Closed Stories interface entirely. - **swipesForward** (required) `integer`: Swiped left to next account's story. - **profileVisits** (required) `integer`: No description - **follows** (required) `integer`: No description - **reposts** (required) `integer`: No description - **totalInteractions** (required) `integer`: No description #### 400: Invalid request. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Instagram account not found. --- --- # List active Instagram stories API Reference Returns the IG Business/Creator account's currently-active stories. Meta keeps stories live for 24h; expired stories are not returned. Limitations propagated from Meta (these are NOT bugs): - 24h window only - Live videos excluded - Reshared stories not returned - `mediaUrl` may be null if Meta flagged the story for copyright - `caption`, `likeCount`, `commentsCount` do not apply to story media ## GET /v1/accounts/{accountId}/instagram/stories **List active Instagram stories** Returns the IG Business/Creator account's currently-active stories. Meta keeps stories live for 24h; expired stories are not returned. Limitations propagated from Meta (these are NOT bugs): - 24h window only - Live videos excluded - Reshared stories not returned - `mediaUrl` may be null if Meta flagged the story for copyright - `caption`, `likeCount`, `commentsCount` do not apply to story media ### Parameters - **accountId** (required) in path: The Instagram account ID ### Responses #### 200: Active stories **Response Body:** - **data** (required) `array[object]`: - **id** (required) `string`: Instagram media ID of the story. - **mediaType** `string`: IMAGE / VIDEO / CAROUSEL_ALBUM - **mediaProductType** `string`: Always 'STORY' for this endpoint. - **mediaUrl** `string`: Direct media URL. Null if Meta flagged the story for copyright. URL expires when the story expires. - **permalink** `string`: Public Instagram permalink to the story (only viewable while live). - **thumbnailUrl** `string`: Thumbnail URL for video stories. - **timestamp** `string` (date-time): When the story was posted. #### 400: Invalid request. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Instagram account not found. --- --- # Create invite token API Reference Generate a secure invite link to grant team members access to your profiles. Invites expire after 7 days and are single-use. ## POST /v1/invite/tokens **Create invite token** Generate a secure invite link to grant team members access to your profiles. Invites expire after 7 days and are single-use. ### Request Body - **scope** (required) `string`: 'all' grants access to all profiles, 'profiles' restricts to specific profiles - one of: all, profiles - **profileIds** `array`: Required if scope is 'profiles'. Array of profile IDs to grant access to. - **role** `string`: Org role granted to the invitee. Defaults to 'member'. 'viewer' creates a read-only member who can view everything in their profile scope but cannot perform any content mutation (publish, edit, delete, connect accounts). - one of: member, billing_admin, viewer - **readOnly** `boolean`: Deprecated. Use role 'viewer' instead. When true, the invite is created with role 'viewer'. Cannot be combined with role 'billing_admin'. ### Responses #### 201: Invite token created **Response Body:** - **token** `string`: No description - **scope** `string`: No description - **invitedProfileIds** `array[string]`: - **expiresAt** `string` (date-time): No description - **inviteUrl** `string` (uri): No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: One or more profiles not found or not owned --- --- # Resolve LinkedIn mention API Reference 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. ## GET /v1/accounts/{accountId}/linkedin-mentions **Resolve LinkedIn mention** 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 - **accountId** (required) in path: The LinkedIn account ID - **url** (required) in query: LinkedIn profile URL, company URL, or vanity name. - **displayName** (optional) in query: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions. ### Responses #### 200: URN resolved successfully **Response Body:** - **urn** `string`: The LinkedIn URN (person or organization) (example: "urn:li:person:4qj5ox-agD") - **type** `string`: The type of entity (person or organization) - one of: person, organization (example: "person") - **displayName** `string`: Display name (provided, from API, or derived from vanity URL) (example: "Miquel Palet") - **mentionFormat** `string`: Ready-to-use mention format for post content (example: "@[Miquel Palet](urn:li:person:4qj5ox-agD)") - **vanityName** `string`: The vanity name/slug (only for organization mentions) (example: "microsoft") - **warning** `string`: 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.") #### 400: Invalid request or no organization found (for person mentions) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Person or organization not found **Response Body:** - **error** `string`: No description --- --- # List activity logs API Reference 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. ## GET /v1/logs **List activity logs** 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. ### Parameters - **type** (optional) in query: Log category to query - **status** (optional) in query: Filter by status - **platform** (optional) in query: Filter by platform - **action** (optional) in query: Filter by action (e.g., post.published, message.sent, account.connected, webhook.delivered) - **search** (optional) in query: Free-text search across log fields - **days** (optional) in query: Number of days to look back (max 90) - **limit** (optional) in query: Maximum number of logs to return (max 100) - **skip** (optional) in query: Number of logs to skip (for pagination) ### Responses #### 200: Logs retrieved successfully **Response Body:** - **logs** `array[object]`: - **type** `string`: Log category (publishing, connections, webhooks, messaging) - **action** `string`: Specific action (post.published, message.sent, account.connected, etc.) - **user_id** `string`: No description - **platform** `string`: No description - **account_id** `string`: No description - **status** `string`: No description - one of: success, failed, pending, skipped - **status_code** `integer`: No description - **error_message** `string`: No description - **error_code** `string`: No description - **duration_ms** `integer`: No description - **endpoint** `string`: The API endpoint that triggered this log - **request_body** `string`: Request JSON (truncated to 5KB) - **response_body** `string`: Response JSON (truncated to 10KB) - **created_at** `string` (date-time): No description - **metadata** `string`: Additional context as JSON string - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **pages** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Get upload URL API Reference 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. ## POST /v1/media/presign **Get upload URL** 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. ### Request Body - **filename** (required) `string`: Name of the file to upload - **contentType** (required) `string`: MIME type of the file - one of: 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 - **size** `integer`: Optional file size in bytes for pre-validation (max 5GB) ### Responses #### 200: Presigned URL generated successfully **Response Body:** - **uploadUrl** `string` (uri): Presigned URL to PUT your file to (expires in 1 hour) - **publicUrl** `string` (uri): Public URL where the file will be accessible after upload - **key** `string`: Storage key/path of the file - **expiresIn** `integer`: Seconds until the presigned uploadUrl expires (always 3600) #### 400: Invalid request (missing filename, contentType, or unsupported content type) **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Add reaction API Reference 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) ## POST /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions **Add reaction** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID to react to ### Request Body - **accountId** (required) `string`: Social account ID - **emoji** (required) `string`: Emoji character (e.g. "👍", "❤️") ### Responses #### 200: Reaction added **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support reactions or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- ## DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions **Remove reaction** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID - **accountId** (required) in query: Social account ID ### Responses #### 200: Reaction removed **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support reactions or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # Create conversation (send a WhatsApp template) API Reference Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread. Supported platforms: X/Twitter, Bluesky, Reddit, and WhatsApp. Other platforms return PLATFORM_NOT_SUPPORTED. WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (body variable values), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED. Templates with media headers (image, video, document) are handled automatically: Zernio reads the approved template definition and fills the header at send time. Calling this for a number you already have a thread with simply sends the template into that thread, which also makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages). Template fields are accepted on the JSON body only, not on multipart requests. DM eligibility (X/Twitter): Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility. X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials. Rate limits: 200 requests per 15 minutes, 1,000 per 24 hours per user, 15,000 per 24 hours per app (shared across all DM endpoints). ## GET /v1/inbox/conversations **List conversations** 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. ### Parameters - **profileId** (optional) in query: Filter by profile ID - **platform** (optional) in query: Filter by platform - **status** (optional) in query: Filter by conversation status - **sortOrder** (optional) in query: Sort order by updated time - **limit** (optional) in query: Maximum number of conversations to return - **cursor** (optional) in query: Pagination cursor for next page - **accountId** (optional) in query: Filter by specific social account ID ### Responses #### 200: Aggregated conversations **Response Body:** - **data** `array[object]`: - **id** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **participantId** `string`: No description - **participantName** `string`: No description - **participantPicture** `string`: No description - **participantVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X conversations. - one of: blue, government, business, none - **lastMessage** `string`: No description - **updatedTime** `string` (date-time): No description - **status** `string`: No description - one of: active, archived - **unreadCount** `integer`: Number of unread messages - **url** `string`: Direct link to open the conversation on the platform (if available) - **instagramProfile** `object`: Instagram profile data for the participant. Only present for Instagram conversations. - **isFollower** `boolean`: Whether the participant follows your Instagram business account - **isFollowing** `boolean`: Whether your Instagram business account follows the participant - **followerCount** `integer`: The participant's follower count on Instagram - **isVerified** `boolean`: Whether the participant is a verified Instagram user - **fetchedAt** `string` (date-time): When this profile data was last fetched from Instagram - **pagination** `object`: - **hasMore** `boolean`: No description - **nextCursor** `string`: No description - **meta** `object`: - **accountsQueried** `integer`: No description - **accountsFailed** `integer`: No description - **failedAccounts** `array[object]`: - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **error** `string`: No description - **code** `string`: Error code if available - **retryAfter** `integer`: Seconds to wait before retry (rate limits) - **lastUpdated** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/conversations **Create conversation (send a WhatsApp template)** Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread. Supported platforms: X/Twitter, Bluesky, Reddit, and WhatsApp. Other platforms return PLATFORM_NOT_SUPPORTED. WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (body variable values), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED. Templates with media headers (image, video, document) are handled automatically: Zernio reads the approved template definition and fills the header at send time. Calling this for a number you already have a thread with simply sends the template into that thread, which also makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages). Template fields are accepted on the JSON body only, not on multipart requests. DM eligibility (X/Twitter): Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility. X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials. Rate limits: 200 requests per 15 minutes, 1,000 per 24 hours per user, 15,000 per 24 hours per app (shared across all DM endpoints). ### Request Body - **accountId** (required) `string`: The social account ID to send from - **participantId** `string`: Recipient identifier. For X this is the numeric user ID; for WhatsApp, the recipient phone number in international format (digits, country code included). Provide either this or participantUsername. - **participantUsername** `string`: Recipient handle/username — an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId. - **message** `string`: Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required. - **skipDmCheck** `boolean`: X/Twitter only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs. - **templateName** `string`: WhatsApp only. Name of the approved template to start the conversation with (required for WhatsApp). - **templateLanguage** `string`: WhatsApp only. Template language code (e.g. en_US). - **templateParams** `array`: WhatsApp only. Body variable values, in order. Works with positional placeholders ({{1}}, {{2}}, ...) and with named placeholders ({{name}}, {{company}} - how Meta Business Manager creates templates), where values fill the named slots in order of appearance. ### Responses #### 201: Conversation created successfully **Response Body:** - **success** `boolean`: No description (example: true) - **data** `object`: - **messageId** `string`: Platform message ID (dm_event_id) - **conversationId** `string`: Platform conversation ID (dm_conversation_id) - **participantId** `string`: Twitter numeric user ID of the recipient - **participantName** `string`: Display name of the recipient - **participantUsername** `string`: Twitter username of the recipient #### 400: Validation error or platform not supported **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: PLATFORM_NOT_SUPPORTED #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required or profile limit reached #### 404: Account or recipient user not found #### 422: Recipient does not accept DMs from this account **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: DM_NOT_ALLOWED #### 429: X API rate limit exceeded --- --- # Delete message API Reference 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) ## PATCH /v1/inbox/conversations/{conversationId}/messages/{messageId} **Edit message** Edit the text and/or reply markup of a previously sent Telegram message. Only supported for Telegram. Returns 400 for other platforms. ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The Telegram message ID to edit ### Request Body - **accountId** (required) `string`: Social account ID - **text** `string`: New message text - **replyMarkup** `object`: New inline keyboard markup ### Responses #### 200: Message edited **Response Body:** - **success** `boolean`: No description - **data** `object`: - **messageId** `integer`: No description #### 400: Not supported or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId} **Delete message** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID to delete - **accountId** (required) in query: Social account ID ### Responses #### 200: Message deleted **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support deletion or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # Edit message API Reference Edit the text and/or reply markup of a previously sent Telegram message. Only supported for Telegram. Returns 400 for other platforms. ## PATCH /v1/inbox/conversations/{conversationId}/messages/{messageId} **Edit message** Edit the text and/or reply markup of a previously sent Telegram message. Only supported for Telegram. Returns 400 for other platforms. ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The Telegram message ID to edit ### Request Body - **accountId** (required) `string`: Social account ID - **text** `string`: New message text - **replyMarkup** `object`: New inline keyboard markup ### Responses #### 200: Message edited **Response Body:** - **success** `boolean`: No description - **data** `object`: - **messageId** `integer`: No description #### 400: Not supported or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId} **Delete message** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID to delete - **accountId** (required) in query: Social account ID ### Responses #### 200: Message deleted **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support deletion or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # List messages API Reference Fetch messages for a specific conversation, with cursor-based pagination and ordering control. Pagination: pass `pagination.nextCursor` from a prior response back as the `cursor` query param to fetch the next page. The cursor is opaque; do not parse or construct it client-side. Sort order: defaults to `asc` (oldest first, chat style). For the "show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`. Twitter, Instagram, Telegram, WhatsApp and Reddit honor the requested order from the local message store. For Facebook and Bluesky, the upstream APIs only return newest-first and have no order parameter — sort order is best-effort and only reverses items within a single page (pages still walk newest→oldest). The response field `sortOrderApplied` tells you what was actually applied. Reddit threads are paginated client-side because Reddit's API has no per-thread cursor. Very long threads may be upstream-truncated by Reddit's inbox/sent windows (~100 most-recent items each); this is a Reddit platform limitation. Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details. This endpoint is read-only and does NOT mark messages as read or send read receipts. To mark a conversation read (and send WhatsApp blue ticks on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`. ## GET /v1/inbox/conversations/{conversationId}/messages **List messages** Fetch messages for a specific conversation, with cursor-based pagination and ordering control. Pagination: pass `pagination.nextCursor` from a prior response back as the `cursor` query param to fetch the next page. The cursor is opaque; do not parse or construct it client-side. Sort order: defaults to `asc` (oldest first, chat style). For the "show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`. Twitter, Instagram, Telegram, WhatsApp and Reddit honor the requested order from the local message store. For Facebook and Bluesky, the upstream APIs only return newest-first and have no order parameter — sort order is best-effort and only reverses items within a single page (pages still walk newest→oldest). The response field `sortOrderApplied` tells you what was actually applied. Reddit threads are paginated client-side because Reddit's API has no per-thread cursor. Very long threads may be upstream-truncated by Reddit's inbox/sent windows (~100 most-recent items each); this is a Reddit platform limitation. Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details. This endpoint is read-only and does NOT mark messages as read or send read receipts. To mark a conversation read (and send WhatsApp blue ticks on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. - **accountId** (required) in query: Social account ID - **limit** (optional) in query: Number of messages to return per page. Default 100, max 100. - **cursor** (optional) in query: Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response. - **sortOrder** (optional) in query: Order of returned messages. Default `asc` (oldest first, chat style). Twitter, Instagram, Telegram, WhatsApp and Reddit honor this order across cursor pages. For Facebook and Bluesky, only intra-page ordering is affected — pages always walk newest→oldest. See `sortOrderApplied` in the response. ### Responses #### 200: Messages in conversation **Response Body:** - **status** `string`: No description - **pagination** `object`: - **hasMore** `boolean`: Whether more messages are available beyond this page. - **nextCursor** `string`: Opaque cursor to fetch the next page. `null` on the last page. - **sortOrderApplied** `string`: Sort order actually applied to the returned page. May differ from the requested `sortOrder` for Facebook and Bluesky (always `desc` regardless of request). - one of: asc, desc - **messages** `array[object]`: - **id** `string`: No description - **conversationId** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **message** `string`: No description - **senderId** `string`: No description - **senderName** `string`: No description - **senderVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X messages. - one of: blue, government, business, none - **direction** `string`: No description - one of: incoming, outgoing - **createdAt** `string` (date-time): No description - **attachments** `array[object]`: - **id** `string`: No description - **type** `string`: No description - one of: image, video, audio, file, sticker, share - **url** `string`: No description - **filename** `string`: No description - **previewUrl** `string`: No description - **subject** `string`: Reddit message subject - **storyReply** `boolean`: Instagram story reply - **isStoryMention** `boolean`: Instagram story mention - **isEdited** `boolean`: True if the sender has edited this message at least once. - **editedAt** `string` (date-time): When the most recent edit happened. - **editCount** `integer`: Total number of edits applied. - **editHistory** `array[object]`: Every prior version of the message, oldest first. - **text** `string`: No description - **attachments** `array[object]`: - **type** `string`: No description - **url** `string`: No description - **payload** `object`: No description - **editedAt** `string` (date-time): No description - **isDeleted** `boolean`: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated. - **deletedAt** `string` (date-time): No description - **deliveryStatus** `string`: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix). - one of: sent, delivered, read, failed, deleted - **deliveredAt** `string` (date-time): No description - **readAt** `string` (date-time): No description - **sentAt** `string` (date-time): Original send time for outgoing messages (used for Messenger watermark queries). - **deliveryError** `object`: Populated when deliveryStatus === "failed". - **code** `integer`: No description - **title** `string`: No description - **message** `string`: No description - **reactions** `array[object]`: Emoji reactions on this message (WhatsApp / Telegram). At most one per party in a 1:1 thread. - **emoji** `string`: No description - **fromMe** `boolean`: true if the connected account reacted - **reactedAt** `string` (date-time): No description - **metadata** `object`: Platform-specific extras. Free-form, but commonly includes: `quotedMessageId` (platformMessageId this message replies to), `waInteractive` (a compact descriptor of WhatsApp interactive content sent: buttons / list / cta_url / flow / location_request), and for inbound interactive taps `interactiveType` / `interactiveId`. - **lastUpdated** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/conversations/{conversationId}/messages **Send message** Send a message in a conversation. Supports text, attachments, quick replies, buttons, templates, and message tags. Attachment and interactive message support varies by platform. WhatsApp template messages: to send an approved template into this conversation (required when the 24-hour customer-service window is closed), use the `template` field with a single element carrying the template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`. See the `template` field below for the exact shape. To send a template to a phone number you have no conversation with yet, use the create-conversation endpoint (POST /v1/inbox/conversations) instead. WhatsApp rich interactive messages (list, CTA URL, Flow, location request) are available via the `interactive` field. Tap events are delivered through the `message.received` webhook with WhatsApp-specific `metadata` fields (`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`). ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. ### Request Body - **accountId** (required) `string`: Social account ID - **message** `string`: Message text - **attachmentUrl** `string`: 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** `string`: Type of attachment. Defaults to file if not specified. - one of: image, video, audio, file - **voiceNote** `boolean`: WhatsApp only. When `true` on an audio attachment, the message is sent as a voice message (PTT) — the recipient sees the waveform + voice-note UI instead of a basic audio attachment. The audio file MUST be `.ogg` encoded with the OPUS codec (mono) per Meta's voice-message contract; other formats are rejected by WhatsApp. Ignored for non-audio attachments. - **quickReplies** `array`: Quick reply buttons. Mutually exclusive with buttons. Max 13 items. - **buttons** `array`: Action buttons. Mutually exclusive with quickReplies. Max 3 items. - **template** `object`: Platform-dependent template payload. Ignored on Telegram. Instagram / Facebook: a generic template (carousel). Set `type: generic` and provide up to 10 `elements`, each with a `title` (required) and optional `subtitle`, `imageUrl`, and `buttons`. WhatsApp: sends an approved WhatsApp template message, the only message type WhatsApp accepts when the 24-hour customer-service window is closed. Provide exactly one element carrying the template reference: `{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }` (`type` is ignored on WhatsApp). `components` is optional and is forwarded unchanged as the `template.components` array of Meta's Cloud API send payload; use it to fill body/header variables and button parameters, e.g. `[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`. Templates with media headers (image, video, document) must include the header component with its media link here at send time. To send a template to a phone number with no existing conversation, or to have media headers filled in automatically from the template definition, use the create-conversation endpoint (POST /v1/inbox/conversations) instead. - **interactive** `object`: WhatsApp-only. Rich interactive payload for list messages, CTA URL buttons, Flow prompts, and location requests. 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`, `flow`, `location_request_message`, or `voice_call` messages. For `voice_call`, the message renders WhatsApp's native call button; tapping it starts a voice call to your business number. Requires WhatsApp Business Calling to be enabled on the sending number. The optional `parameters.payload` string is echoed back on the `calls` webhook (as `cta_payload`) for attribution. For `location_request_message`, `action` may be omitted (we default it to `{ "name": "send_location" }`). WhatsApp renders a localized "Send location" button; the user's reply arrives as a regular location message in the conversation. Tap events come back via the `message.received` webhook with `metadata.interactiveType` set to `list_reply` or `nfm_reply`. - **replyMarkup** `object`: Telegram-native keyboard markup. Ignored on other platforms. - **messagingType** `string`: Facebook messaging type. Required when using messageTag. - one of: RESPONSE, UPDATE, MESSAGE_TAG - **messageTag** `string`: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT. - one of: CONFIRMED_EVENT_UPDATE, POST_PURCHASE_UPDATE, ACCOUNT_UPDATE, HUMAN_AGENT - **replyTo** `string`: 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. - **location** `object`: WhatsApp-only. Send a location pin. - **contacts** `array`: WhatsApp-only. Send one or more contact cards. ### Responses #### 200: Message sent **Response Body:** - **success** `boolean`: No description - **data** `object`: - **messageId** `string`: ID of the sent message (not returned for Reddit) - **conversationId** `string`: Twitter conversation ID - **sentAt** `string` (date-time): Bluesky sent timestamp - **message** `string`: Success message (Reddit only) #### 400: Bad request (e.g., attachment not supported for platform, validation error) **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: PLATFORM_LIMITATION #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Get conversation API Reference Retrieve details and metadata for a specific conversation. Requires accountId query parameter. ## GET /v1/inbox/conversations/{conversationId} **Get conversation** Retrieve details and metadata for a specific conversation. Requires accountId query parameter. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. - **accountId** (required) in query: The social account ID ### Responses #### 200: Conversation details **Response Body:** - **data** `object`: - **id** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **status** `string`: No description - one of: active, archived - **participantName** `string`: No description - **participantId** `string`: No description - **participantVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X conversations. - one of: blue, government, business, none - **lastMessage** `string`: No description - **lastMessageAt** `string` (date-time): No description - **updatedTime** `string` (date-time): No description - **participants** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **instagramProfile** `object`: Instagram profile data for the participant. Only present for Instagram conversations. - **isFollower** `boolean`: Whether the participant follows your Instagram business account - **isFollowing** `boolean`: Whether your Instagram business account follows the participant - **followerCount** `integer`: The participant's follower count on Instagram - **isVerified** `boolean`: Whether the participant is a verified Instagram user - **fetchedAt** `string` (date-time): When this profile data was last fetched from Instagram #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Conversation not found --- ## PUT /v1/inbox/conversations/{conversationId} **Update conversation status** Archive or activate a conversation. Requires accountId in request body. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. ### Request Body - **accountId** (required) `string`: Social account ID - **status** (required) `string`: No description - one of: active, archived ### Responses #### 200: Conversation updated **Response Body:** - **success** `boolean`: No description - **data** `object`: - **id** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - one of: active, archived - **platform** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Conversation not found (WhatsApp only; other platforms upsert) --- --- # List conversations API Reference 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. ## GET /v1/inbox/conversations **List conversations** 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. ### Parameters - **profileId** (optional) in query: Filter by profile ID - **platform** (optional) in query: Filter by platform - **status** (optional) in query: Filter by conversation status - **sortOrder** (optional) in query: Sort order by updated time - **limit** (optional) in query: Maximum number of conversations to return - **cursor** (optional) in query: Pagination cursor for next page - **accountId** (optional) in query: Filter by specific social account ID ### Responses #### 200: Aggregated conversations **Response Body:** - **data** `array[object]`: - **id** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **participantId** `string`: No description - **participantName** `string`: No description - **participantPicture** `string`: No description - **participantVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X conversations. - one of: blue, government, business, none - **lastMessage** `string`: No description - **updatedTime** `string` (date-time): No description - **status** `string`: No description - one of: active, archived - **unreadCount** `integer`: Number of unread messages - **url** `string`: Direct link to open the conversation on the platform (if available) - **instagramProfile** `object`: Instagram profile data for the participant. Only present for Instagram conversations. - **isFollower** `boolean`: Whether the participant follows your Instagram business account - **isFollowing** `boolean`: Whether your Instagram business account follows the participant - **followerCount** `integer`: The participant's follower count on Instagram - **isVerified** `boolean`: Whether the participant is a verified Instagram user - **fetchedAt** `string` (date-time): When this profile data was last fetched from Instagram - **pagination** `object`: - **hasMore** `boolean`: No description - **nextCursor** `string`: No description - **meta** `object`: - **accountsQueried** `integer`: No description - **accountsFailed** `integer`: No description - **failedAccounts** `array[object]`: - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **error** `string`: No description - **code** `string`: Error code if available - **retryAfter** `integer`: Seconds to wait before retry (rate limits) - **lastUpdated** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/conversations **Create conversation (send a WhatsApp template)** Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread. Supported platforms: X/Twitter, Bluesky, Reddit, and WhatsApp. Other platforms return PLATFORM_NOT_SUPPORTED. WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (body variable values), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED. Templates with media headers (image, video, document) are handled automatically: Zernio reads the approved template definition and fills the header at send time. Calling this for a number you already have a thread with simply sends the template into that thread, which also makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages). Template fields are accepted on the JSON body only, not on multipart requests. DM eligibility (X/Twitter): Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility. X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials. Rate limits: 200 requests per 15 minutes, 1,000 per 24 hours per user, 15,000 per 24 hours per app (shared across all DM endpoints). ### Request Body - **accountId** (required) `string`: The social account ID to send from - **participantId** `string`: Recipient identifier. For X this is the numeric user ID; for WhatsApp, the recipient phone number in international format (digits, country code included). Provide either this or participantUsername. - **participantUsername** `string`: Recipient handle/username — an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId. - **message** `string`: Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required. - **skipDmCheck** `boolean`: X/Twitter only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs. - **templateName** `string`: WhatsApp only. Name of the approved template to start the conversation with (required for WhatsApp). - **templateLanguage** `string`: WhatsApp only. Template language code (e.g. en_US). - **templateParams** `array`: WhatsApp only. Body variable values, in order. Works with positional placeholders ({{1}}, {{2}}, ...) and with named placeholders ({{name}}, {{company}} - how Meta Business Manager creates templates), where values fill the named slots in order of appearance. ### Responses #### 201: Conversation created successfully **Response Body:** - **success** `boolean`: No description (example: true) - **data** `object`: - **messageId** `string`: Platform message ID (dm_event_id) - **conversationId** `string`: Platform conversation ID (dm_conversation_id) - **participantId** `string`: Twitter numeric user ID of the recipient - **participantName** `string`: Display name of the recipient - **participantUsername** `string`: Twitter username of the recipient #### 400: Validation error or platform not supported **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: PLATFORM_NOT_SUPPORTED #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required or profile limit reached #### 404: Account or recipient user not found #### 422: Recipient does not accept DMs from this account **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: DM_NOT_ALLOWED #### 429: X API rate limit exceeded --- --- # Mark a conversation as read API Reference Marks all unread incoming messages in the conversation as read. For WhatsApp, this also sends read receipts (blue ticks) to the contact, EXCEPT on coexistence accounts (where the WhatsApp Business app on the customer's phone owns read state and we never override it). This is the explicit, human-driven counterpart to `GET .../messages`, which is side-effect-free and does NOT mark anything read. Call this when a user actually views the conversation. ## POST /v1/inbox/conversations/{conversationId}/read **Mark a conversation as read** Marks all unread incoming messages in the conversation as read. For WhatsApp, this also sends read receipts (blue ticks) to the contact, EXCEPT on coexistence accounts (where the WhatsApp Business app on the customer's phone owns read state and we never override it). This is the explicit, human-driven counterpart to `GET .../messages`, which is side-effect-free and does NOT mark anything read. Call this when a user actually views the conversation. ### Parameters - **conversationId** (required) in path: The conversation ID ### Request Body - **accountId** (required) `string`: Social account ID ### Responses #### 200: Conversation marked read **Response Body:** - **success** `boolean`: No description - **markedCount** `integer`: Number of messages marked read by this call #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # Remove reaction API Reference 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) ## POST /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions **Add reaction** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID to react to ### Request Body - **accountId** (required) `string`: Social account ID - **emoji** (required) `string`: Emoji character (e.g. "👍", "❤️") ### Responses #### 200: Reaction added **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support reactions or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- ## DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions **Remove reaction** 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) ### Parameters - **conversationId** (required) in path: The conversation ID - **messageId** (required) in path: The platform message ID - **accountId** (required) in query: Social account ID ### Responses #### 200: Reaction removed **Response Body:** - **success** `boolean`: No description #### 400: Platform does not support reactions or invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # Send message API Reference Send a message in a conversation. Supports text, attachments, quick replies, buttons, templates, and message tags. Attachment and interactive message support varies by platform. WhatsApp template messages: to send an approved template into this conversation (required when the 24-hour customer-service window is closed), use the `template` field with a single element carrying the template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`. See the `template` field below for the exact shape. To send a template to a phone number you have no conversation with yet, use the create-conversation endpoint (POST /v1/inbox/conversations) instead. WhatsApp rich interactive messages (list, CTA URL, Flow, location request) are available via the `interactive` field. Tap events are delivered through the `message.received` webhook with WhatsApp-specific `metadata` fields (`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`). ## GET /v1/inbox/conversations/{conversationId}/messages **List messages** Fetch messages for a specific conversation, with cursor-based pagination and ordering control. Pagination: pass `pagination.nextCursor` from a prior response back as the `cursor` query param to fetch the next page. The cursor is opaque; do not parse or construct it client-side. Sort order: defaults to `asc` (oldest first, chat style). For the "show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`. Twitter, Instagram, Telegram, WhatsApp and Reddit honor the requested order from the local message store. For Facebook and Bluesky, the upstream APIs only return newest-first and have no order parameter — sort order is best-effort and only reverses items within a single page (pages still walk newest→oldest). The response field `sortOrderApplied` tells you what was actually applied. Reddit threads are paginated client-side because Reddit's API has no per-thread cursor. Very long threads may be upstream-truncated by Reddit's inbox/sent windows (~100 most-recent items each); this is a Reddit platform limitation. Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details. This endpoint is read-only and does NOT mark messages as read or send read receipts. To mark a conversation read (and send WhatsApp blue ticks on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. - **accountId** (required) in query: Social account ID - **limit** (optional) in query: Number of messages to return per page. Default 100, max 100. - **cursor** (optional) in query: Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response. - **sortOrder** (optional) in query: Order of returned messages. Default `asc` (oldest first, chat style). Twitter, Instagram, Telegram, WhatsApp and Reddit honor this order across cursor pages. For Facebook and Bluesky, only intra-page ordering is affected — pages always walk newest→oldest. See `sortOrderApplied` in the response. ### Responses #### 200: Messages in conversation **Response Body:** - **status** `string`: No description - **pagination** `object`: - **hasMore** `boolean`: Whether more messages are available beyond this page. - **nextCursor** `string`: Opaque cursor to fetch the next page. `null` on the last page. - **sortOrderApplied** `string`: Sort order actually applied to the returned page. May differ from the requested `sortOrder` for Facebook and Bluesky (always `desc` regardless of request). - one of: asc, desc - **messages** `array[object]`: - **id** `string`: No description - **conversationId** `string`: No description - **accountId** `string`: No description - **platform** `string`: No description - **message** `string`: No description - **senderId** `string`: No description - **senderName** `string`: No description - **senderVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X messages. - one of: blue, government, business, none - **direction** `string`: No description - one of: incoming, outgoing - **createdAt** `string` (date-time): No description - **attachments** `array[object]`: - **id** `string`: No description - **type** `string`: No description - one of: image, video, audio, file, sticker, share - **url** `string`: No description - **filename** `string`: No description - **previewUrl** `string`: No description - **subject** `string`: Reddit message subject - **storyReply** `boolean`: Instagram story reply - **isStoryMention** `boolean`: Instagram story mention - **isEdited** `boolean`: True if the sender has edited this message at least once. - **editedAt** `string` (date-time): When the most recent edit happened. - **editCount** `integer`: Total number of edits applied. - **editHistory** `array[object]`: Every prior version of the message, oldest first. - **text** `string`: No description - **attachments** `array[object]`: - **type** `string`: No description - **url** `string`: No description - **payload** `object`: No description - **editedAt** `string` (date-time): No description - **isDeleted** `boolean`: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated. - **deletedAt** `string` (date-time): No description - **deliveryStatus** `string`: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix). - one of: sent, delivered, read, failed, deleted - **deliveredAt** `string` (date-time): No description - **readAt** `string` (date-time): No description - **sentAt** `string` (date-time): Original send time for outgoing messages (used for Messenger watermark queries). - **deliveryError** `object`: Populated when deliveryStatus === "failed". - **code** `integer`: No description - **title** `string`: No description - **message** `string`: No description - **reactions** `array[object]`: Emoji reactions on this message (WhatsApp / Telegram). At most one per party in a 1:1 thread. - **emoji** `string`: No description - **fromMe** `boolean`: true if the connected account reacted - **reactedAt** `string` (date-time): No description - **metadata** `object`: Platform-specific extras. Free-form, but commonly includes: `quotedMessageId` (platformMessageId this message replies to), `waInteractive` (a compact descriptor of WhatsApp interactive content sent: buttons / list / cta_url / flow / location_request), and for inbound interactive taps `interactiveType` / `interactiveId`. - **lastUpdated** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## POST /v1/inbox/conversations/{conversationId}/messages **Send message** Send a message in a conversation. Supports text, attachments, quick replies, buttons, templates, and message tags. Attachment and interactive message support varies by platform. WhatsApp template messages: to send an approved template into this conversation (required when the 24-hour customer-service window is closed), use the `template` field with a single element carrying the template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`. See the `template` field below for the exact shape. To send a template to a phone number you have no conversation with yet, use the create-conversation endpoint (POST /v1/inbox/conversations) instead. WhatsApp rich interactive messages (list, CTA URL, Flow, location request) are available via the `interactive` field. Tap events are delivered through the `message.received` webhook with WhatsApp-specific `metadata` fields (`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`). ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. ### Request Body - **accountId** (required) `string`: Social account ID - **message** `string`: Message text - **attachmentUrl** `string`: 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** `string`: Type of attachment. Defaults to file if not specified. - one of: image, video, audio, file - **voiceNote** `boolean`: WhatsApp only. When `true` on an audio attachment, the message is sent as a voice message (PTT) — the recipient sees the waveform + voice-note UI instead of a basic audio attachment. The audio file MUST be `.ogg` encoded with the OPUS codec (mono) per Meta's voice-message contract; other formats are rejected by WhatsApp. Ignored for non-audio attachments. - **quickReplies** `array`: Quick reply buttons. Mutually exclusive with buttons. Max 13 items. - **buttons** `array`: Action buttons. Mutually exclusive with quickReplies. Max 3 items. - **template** `object`: Platform-dependent template payload. Ignored on Telegram. Instagram / Facebook: a generic template (carousel). Set `type: generic` and provide up to 10 `elements`, each with a `title` (required) and optional `subtitle`, `imageUrl`, and `buttons`. WhatsApp: sends an approved WhatsApp template message, the only message type WhatsApp accepts when the 24-hour customer-service window is closed. Provide exactly one element carrying the template reference: `{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }` (`type` is ignored on WhatsApp). `components` is optional and is forwarded unchanged as the `template.components` array of Meta's Cloud API send payload; use it to fill body/header variables and button parameters, e.g. `[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`. Templates with media headers (image, video, document) must include the header component with its media link here at send time. To send a template to a phone number with no existing conversation, or to have media headers filled in automatically from the template definition, use the create-conversation endpoint (POST /v1/inbox/conversations) instead. - **interactive** `object`: WhatsApp-only. Rich interactive payload for list messages, CTA URL buttons, Flow prompts, and location requests. 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`, `flow`, `location_request_message`, or `voice_call` messages. For `voice_call`, the message renders WhatsApp's native call button; tapping it starts a voice call to your business number. Requires WhatsApp Business Calling to be enabled on the sending number. The optional `parameters.payload` string is echoed back on the `calls` webhook (as `cta_payload`) for attribution. For `location_request_message`, `action` may be omitted (we default it to `{ "name": "send_location" }`). WhatsApp renders a localized "Send location" button; the user's reply arrives as a regular location message in the conversation. Tap events come back via the `message.received` webhook with `metadata.interactiveType` set to `list_reply` or `nfm_reply`. - **replyMarkup** `object`: Telegram-native keyboard markup. Ignored on other platforms. - **messagingType** `string`: Facebook messaging type. Required when using messageTag. - one of: RESPONSE, UPDATE, MESSAGE_TAG - **messageTag** `string`: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT. - one of: CONFIRMED_EVENT_UPDATE, POST_PURCHASE_UPDATE, ACCOUNT_UPDATE, HUMAN_AGENT - **replyTo** `string`: 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. - **location** `object`: WhatsApp-only. Send a location pin. - **contacts** `array`: WhatsApp-only. Send one or more contact cards. ### Responses #### 200: Message sent **Response Body:** - **success** `boolean`: No description - **data** `object`: - **messageId** `string`: ID of the sent message (not returned for Reddit) - **conversationId** `string`: Twitter conversation ID - **sentAt** `string` (date-time): Bluesky sent timestamp - **message** `string`: Success message (Reddit only) #### 400: Bad request (e.g., attachment not supported for platform, validation error) **Response Body:** - **error** `string`: No description - **code** `string`: No description - one of: PLATFORM_LIMITATION #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Send typing indicator API Reference 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 - WhatsApp: Shows "typing..." for up to 25 seconds. Requires a recent inbound message in the conversation (Meta references the inbound message id) and also marks that message as read as a side-effect. - All others: Returns 200 but no-op (platform doesn't support it) Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails. ## POST /v1/inbox/conversations/{conversationId}/typing **Send typing indicator** 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 - WhatsApp: Shows "typing..." for up to 25 seconds. Requires a recent inbound message in the conversation (Meta references the inbound message id) and also marks that message as read as a side-effect. - All others: Returns 200 but no-op (platform doesn't support it) Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails. ### Parameters - **conversationId** (required) in path: The conversation ID ### Request Body - **accountId** (required) `string`: Social account ID ### Responses #### 200: Typing indicator sent (or no-op on unsupported platforms) **Response Body:** - **success** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Account or conversation not found --- --- # Update conversation status API Reference Archive or activate a conversation. Requires accountId in request body. ## GET /v1/inbox/conversations/{conversationId} **Get conversation** Retrieve details and metadata for a specific conversation. Requires accountId query parameter. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. - **accountId** (required) in query: The social account ID ### Responses #### 200: Conversation details **Response Body:** - **data** `object`: - **id** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **status** `string`: No description - one of: active, archived - **participantName** `string`: No description - **participantId** `string`: No description - **participantVerifiedType** `string`: X/Twitter verified badge type. Only present for Twitter/X conversations. - one of: blue, government, business, none - **lastMessage** `string`: No description - **lastMessageAt** `string` (date-time): No description - **updatedTime** `string` (date-time): No description - **participants** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **instagramProfile** `object`: Instagram profile data for the participant. Only present for Instagram conversations. - **isFollower** `boolean`: Whether the participant follows your Instagram business account - **isFollowing** `boolean`: Whether your Instagram business account follows the participant - **followerCount** `integer`: The participant's follower count on Instagram - **isVerified** `boolean`: Whether the participant is a verified Instagram user - **fetchedAt** `string` (date-time): When this profile data was last fetched from Instagram #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Conversation not found --- ## PUT /v1/inbox/conversations/{conversationId} **Update conversation status** Archive or activate a conversation. Requires accountId in request body. ### Parameters - **conversationId** (required) in path: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. ### Request Body - **accountId** (required) `string`: Social account ID - **status** (required) `string`: No description - one of: active, archived ### Responses #### 200: Conversation updated **Response Body:** - **success** `boolean`: No description - **data** `object`: - **id** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - one of: active, archived - **platform** `string`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required #### 404: Conversation not found (WhatsApp only; other platforms upsert) --- --- # Upload media file API Reference 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. ## POST /v1/media/upload-direct **Upload media file** 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. ### Request Body ### Responses #### 200: File uploaded successfully **Response Body:** - **url** `string`: Publicly accessible URL for the uploaded file - **filename** `string`: Generated unique filename - **contentType** `string`: MIME type of the file - **size** `integer`: File size in bytes #### 400: No file provided or file too large #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Bulk upload from CSV API Reference Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts. ## POST /v1/posts/bulk-upload **Bulk upload from CSV** Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts. ### Parameters - **dryRun** (optional) in query: No description ### Request Body ### Responses #### 200: Bulk upload results. Returned when every row succeeded (or every row failed). A mix of successes and failures returns `207` instead, with the same body shape. **Response Body:** - **total** `integer`: Number of data rows processed from the CSV - **valid** `integer`: Count of rows that succeeded (results[].ok === true) - **invalid** `integer`: Count of rows that failed (total - valid) - **results** `array[object]`: One entry per CSV data row, in row order. - **rowIndex** `integer`: 1-based index of the CSV data row (header excluded) - **ok** `boolean`: Whether the row was created successfully - **createdPostId** `string`: ID of the created post. Present only when `ok` is true and not a dry run. - **errors** `array[string]`: Machine-readable failure codes for this row. Present only when `ok` is false. Examples: `unknown_profile:`, `no_account_for_platform:`, `schedule_time_missing`, `rate_limited::@:`. - **warnings** `array[string]`: Top-level advisory warnings (e.g. `rows_exceed_advisory_limit:500`). Empty when none. - **rateLimitedAccounts** `array[object]`: Present only when one or more rows targeted an account currently in cooldown. Lets callers map `rate_limited:*` row errors back to structured metadata without parsing the error strings. - **accountId** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **rateLimitedUntil** `string` (date-time): No description #### 207: Partial success: some rows were created and some failed. Body is identical in shape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`) to see which rows failed and why. **Response Body:** - **total** `integer`: Number of data rows processed from the CSV - **valid** `integer`: Count of rows that succeeded (results[].ok === true) - **invalid** `integer`: Count of rows that failed (total - valid) - **results** `array[object]`: One entry per CSV data row, in row order. - **rowIndex** `integer`: 1-based index of the CSV data row (header excluded) - **ok** `boolean`: Whether the row was created successfully - **createdPostId** `string`: ID of the created post. Present only when `ok` is true and not a dry run. - **errors** `array[string]`: Machine-readable failure codes for this row. Present only when `ok` is false. Examples: `unknown_profile:`, `no_account_for_platform:`, `schedule_time_missing`, `rate_limited::@:`. - **warnings** `array[string]`: Top-level advisory warnings (e.g. `rows_exceed_advisory_limit:500`). Empty when none. - **rateLimitedAccounts** `array[object]`: Present only when one or more rows targeted an account currently in cooldown. Lets callers map `rate_limited:*` row errors back to structured metadata without parsing the error strings. - **accountId** `string`: No description - **platform** `string`: No description - **username** `string`: No description - **rateLimitedUntil** `string` (date-time): No description #### 400: Invalid CSV or validation errors #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 429: 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). **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## BulkUploadResult Result of a CSV bulk upload. The same shape is returned for `200` (all rows succeeded or all failed) and `207` (mixed). Per-row outcomes live in `results`; the row's success is `ok`, and failures carry machine-readable codes in `errors`. ### Properties - **total** `integer`: Number of data rows processed from the CSV - **valid** `integer`: Count of rows that succeeded (results[].ok === true) - **invalid** `integer`: Count of rows that failed (total - valid) - **results** `array`: One entry per CSV data row, in row order. - **warnings** `array`: Top-level advisory warnings (e.g. `rows_exceed_advisory_limit:500`). Empty when none. - **rateLimitedAccounts** `array`: Present only when one or more rows targeted an account currently in cooldown. Lets callers map `rate_limited:*` row errors back to structured metadata without parsing the error strings. --- # Create post API Reference Create and optionally publish a post. Immediate posts (`publishNow: true`) include `platformPostUrl` in the response. Content is optional when media is attached or all platforms have `customContent`. See each platform's schema for media constraints. ## Idempotency Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post. **1. Same-request idempotency (5-minute window).** Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field — no new post is created. The official Zernio SDKs auto-generate a unique `x-request-id` per call. If you're using a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either: - Set a unique `x-request-id` per logical call (recommended — UUIDv4 is fine) - Or simply omit the header — we'll treat each request as new **Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node. **2. Content-hash dedup (24-hour window).** Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. Returns `error`, `accountId`, `platform`, and `existingPostId` so you can find the original. To intentionally re-post identical content within 24h, change something (the caption, the media, the account) — the dedup is keyed on the full content fingerprint. Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs. ## GET /v1/posts **List posts** Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform. ### Parameters - **undefined** (optional): No description - **undefined** (optional): No description - **status** (optional) in query: No description - **platform** (optional) in query: No description - **profileId** (optional) in query: No description - **createdBy** (optional) in query: No description - **dateFrom** (optional) in query: No description - **dateTo** (optional) in query: No description - **includeHidden** (optional) in query: No description - **search** (optional) in query: Search posts by text content. - **sortBy** (optional) in query: Sort order for results. - **accountId** (optional) in query: Filter posts to those published via a specific social account (24-char hex ObjectId). ### Responses #### 200: Paginated posts **Response Body:** - **posts** `array[Post]`: - **pagination**: `Pagination` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/posts **Create post** Create and optionally publish a post. Immediate posts (`publishNow: true`) include `platformPostUrl` in the response. Content is optional when media is attached or all platforms have `customContent`. See each platform's schema for media constraints. ## Idempotency Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post. **1. Same-request idempotency (5-minute window).** Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field — no new post is created. The official Zernio SDKs auto-generate a unique `x-request-id` per call. If you're using a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either: - Set a unique `x-request-id` per logical call (recommended — UUIDv4 is fine) - Or simply omit the header — we'll treat each request as new **Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node. **2. Content-hash dedup (24-hour window).** Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. Returns `error`, `accountId`, `platform`, and `existingPostId` so you can find the original. To intentionally re-post identical content within 24h, change something (the caption, the media, the account) — the dedup is keyed on the full content fingerprint. Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs. ### Parameters - **x-request-id** (optional) in header: Optional client-generated request identifier for safe retry (idempotency). When two requests carry the same value, the second is treated as a retry of the first and returns the original post (HTTP 200) instead of creating a duplicate. Window is ~5 minutes from the first request. Generate a UUID per logical call. SDKs do this automatically; HTTP clients should set it themselves or omit it. See the operation description for the full idempotency contract. ### Request Body - **title** `string`: No description - **content** `string`: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts. - **mediaItems** `array`: No description - **platforms** `array`: Target platforms and accounts for this post. Required for non-draft posts (returns 400 if empty). Drafts can omit platforms. - **scheduledFor** `string`: No description - **publishNow** `boolean`: No description - **isDraft** `boolean`: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically. - **timezone** `string`: No description - **tags** `array`: Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **crosspostingEnabled** `boolean`: No description - **metadata** `object`: No description - **tiktokSettings**: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **facebookSettings**: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **recycling**: No description - **queuedFromProfile** `string`: 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** `string`: Specific queue ID to use when scheduling via queue. Only used when queuedFromProfile is also provided. If omitted, uses the profile's default queue. ### Responses #### 201: Post created **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 400: Validation error **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden **Response Body:** - **error** `string`: No description #### 409: Duplicate content detected. Returned when the requested post matches an existing one on `(platform, accountId, content-hash)` within the last 24 hours, AND the request was NOT an `x-request-id` retry of an in-flight call. Distinct from same-`x-request-id` retries (which return HTTP 200 with the original post — see operation description for the idempotency contract). Body fields: - `error` — human-readable message - `details.accountId` — the account that already has this content - `details.platform` — the platform that already has this content - `details.existingPostId` — Zernio `_id` of the original post To intentionally re-post identical content within 24h, vary the content fingerprint (change the caption, swap a media item, or use a different account). To avoid 409s caused by retry loops, set a unique `x-request-id` per logical request — see `parameters.x-request-id` above. **Response Body:** - **error** `string`: No description (example: "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours.") - **details** `object`: - **accountId** `string`: No description - **platform** `string`: No description - **existingPostId** `string`: No description #### 429: Rate limit exceeded. Possible causes: API rate limit, velocity limit (15 posts/hour per account), account cooldown, or daily platform limits. **Response Body:** - **error** `string`: No description - **details** `object`: Additional context about the rate limit --- # Related Schema Definitions ## PostsListResponse ### Properties - **posts** `array`: No description - **pagination**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description ## TikTokPlatformData 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** `boolean`: 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** `string`: One of the values returned by the TikTok creator info API for the account - **allowComment** `boolean`: Allow comments on the post - **allowDuet** `boolean`: Allow duets (required for video posts) - **allowStitch** `boolean`: Allow stitches (required for video posts) - **commercialContentType** `string`: Type of commercial content disclosure - one of: none, brand_organic, brand_content - **brandPartnerPromote** `boolean`: Whether the post promotes a brand partner - **isBrandOrganicPost** `boolean`: Whether the post is a brand organic post - **contentPreviewConfirmed** `boolean`: User has confirmed they previewed the content - **expressConsentGiven** `boolean`: User has given express consent for posting - **mediaType** `string`: Optional override. Defaults based on provided media items. - one of: video, photo - **videoCoverTimestampMs** `integer`: 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. (min: 0) - **videoCoverImageUrl** `string`: 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** `integer`: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). (min: 0) - **autoAddMusic** `boolean`: When true, TikTok may add recommended music (photos only) - **videoMadeWithAi** `boolean`: Set true to disclose AI-generated content - **description** `string`: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. (max: 4000) ## FacebookPlatformData 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). Carousel posts (carouselCards) render a 2-5 card multi-link post, images only, mutually exclusive with story/reel. Geo-restriction is a hard visibility restriction: users outside the specified countries cannot see the post. Not supported for stories. ### Properties - **draft** `boolean`: 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** `string`: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. - one of: story, reel - **title** `string`: Reel title (only for contentType=reel). Separate from the caption/content field. - **firstComment** `string`: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true. - **pageId** `string`: 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**: No description - **carouselCards** `array`: Renders the post as a multi-link carousel (organic Page post). When set, mediaItems must be provided with the same length and all items must be images (no videos). Each cards[i] adds the click-through link and headline for the image at mediaItems[i]. Mutually exclusive with contentType=story|reel. Facebook display truncates name at ~35 chars and description at ~30 chars; longer strings are accepted but get truncated on render. - **carouselLink** `string`: Optional top-level "See more" destination shown on the carousel end card. Defaults to the first card's link when omitted. Only used together with carouselCards. ## PostCreateResponse ### Properties - **message** `string`: No description - **post**: No description --- # Delete post API Reference Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded. ## GET /v1/posts/{postId} **Get post** Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Post **Response Body:** - **post**: `Post` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/posts/{postId} **Update post** Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited. Published posts can only have their recycling config updated. ### Parameters - **postId** (required) in path: No description ### Request Body - **title** `string`: No description - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: Target platforms and accounts for this post. Each item must include platform and accountId. - **scheduledFor** `string`: No description - **publishNow** `boolean`: No description - **isDraft** `boolean`: No description - **timezone** `string`: No description - **visibility** `string`: No description - one of: public, private, unlisted - **tags** `array`: No description - **hashtags** `array`: No description - **mentions** `array`: No description - **crosspostingEnabled** `boolean`: No description - **metadata** `object`: No description - **queuedFromProfile** `string`: Profile ID to schedule via queue. - **queueId** `string`: Specific queue ID to use when scheduling via queue. - **tiktokSettings**: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **facebookSettings**: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **recycling**: No description ### Responses #### 200: Post updated **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 207: Partial publish success #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/posts/{postId} **Delete post** Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Cannot delete published posts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## PostGetResponse ### Properties - **post**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description ## TikTokPlatformData 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** `boolean`: 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** `string`: One of the values returned by the TikTok creator info API for the account - **allowComment** `boolean`: Allow comments on the post - **allowDuet** `boolean`: Allow duets (required for video posts) - **allowStitch** `boolean`: Allow stitches (required for video posts) - **commercialContentType** `string`: Type of commercial content disclosure - one of: none, brand_organic, brand_content - **brandPartnerPromote** `boolean`: Whether the post promotes a brand partner - **isBrandOrganicPost** `boolean`: Whether the post is a brand organic post - **contentPreviewConfirmed** `boolean`: User has confirmed they previewed the content - **expressConsentGiven** `boolean`: User has given express consent for posting - **mediaType** `string`: Optional override. Defaults based on provided media items. - one of: video, photo - **videoCoverTimestampMs** `integer`: 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. (min: 0) - **videoCoverImageUrl** `string`: 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** `integer`: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). (min: 0) - **autoAddMusic** `boolean`: When true, TikTok may add recommended music (photos only) - **videoMadeWithAi** `boolean`: Set true to disclose AI-generated content - **description** `string`: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. (max: 4000) ## FacebookPlatformData 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). Carousel posts (carouselCards) render a 2-5 card multi-link post, images only, mutually exclusive with story/reel. Geo-restriction is a hard visibility restriction: users outside the specified countries cannot see the post. Not supported for stories. ### Properties - **draft** `boolean`: 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** `string`: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. - one of: story, reel - **title** `string`: Reel title (only for contentType=reel). Separate from the caption/content field. - **firstComment** `string`: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true. - **pageId** `string`: 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**: No description - **carouselCards** `array`: Renders the post as a multi-link carousel (organic Page post). When set, mediaItems must be provided with the same length and all items must be images (no videos). Each cards[i] adds the click-through link and headline for the image at mediaItems[i]. Mutually exclusive with contentType=story|reel. Facebook display truncates name at ~35 chars and description at ~30 chars; longer strings are accepted but get truncated on render. - **carouselLink** `string`: Optional top-level "See more" destination shown on the carousel end card. Defaults to the first card's link when omitted. Only used together with carouselCards. ## PostUpdateResponse ### Properties - **message** `string`: No description - **post**: No description ## PostDeleteResponse ### Properties - **message** `string`: No description --- # Edit published post API Reference 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. ## POST /v1/posts/{postId}/edit **Edit published post** 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 - **postId** (required) in path: No description ### Request Body - **platform** (required) `string`: The platform to edit the post on. Currently only twitter is supported. - one of: twitter - **content** (required) `string`: The new tweet text content ### Responses #### 200: Post edited successfully **Response Body:** - **success** `boolean`: No description - **id** `string`: New tweet ID assigned by X after edit - **url** `string` (uri): URL of the edited tweet - **message** `string`: No description #### 400: Invalid request: platform not supported, post not published, edit window expired, not Premium, or missing content. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Platform API edit failed --- --- # Get post API Reference Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. ## GET /v1/posts/{postId} **Get post** Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Post **Response Body:** - **post**: `Post` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/posts/{postId} **Update post** Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited. Published posts can only have their recycling config updated. ### Parameters - **postId** (required) in path: No description ### Request Body - **title** `string`: No description - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: Target platforms and accounts for this post. Each item must include platform and accountId. - **scheduledFor** `string`: No description - **publishNow** `boolean`: No description - **isDraft** `boolean`: No description - **timezone** `string`: No description - **visibility** `string`: No description - one of: public, private, unlisted - **tags** `array`: No description - **hashtags** `array`: No description - **mentions** `array`: No description - **crosspostingEnabled** `boolean`: No description - **metadata** `object`: No description - **queuedFromProfile** `string`: Profile ID to schedule via queue. - **queueId** `string`: Specific queue ID to use when scheduling via queue. - **tiktokSettings**: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **facebookSettings**: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **recycling**: No description ### Responses #### 200: Post updated **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 207: Partial publish success #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/posts/{postId} **Delete post** Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Cannot delete published posts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## PostGetResponse ### Properties - **post**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description ## TikTokPlatformData 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** `boolean`: 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** `string`: One of the values returned by the TikTok creator info API for the account - **allowComment** `boolean`: Allow comments on the post - **allowDuet** `boolean`: Allow duets (required for video posts) - **allowStitch** `boolean`: Allow stitches (required for video posts) - **commercialContentType** `string`: Type of commercial content disclosure - one of: none, brand_organic, brand_content - **brandPartnerPromote** `boolean`: Whether the post promotes a brand partner - **isBrandOrganicPost** `boolean`: Whether the post is a brand organic post - **contentPreviewConfirmed** `boolean`: User has confirmed they previewed the content - **expressConsentGiven** `boolean`: User has given express consent for posting - **mediaType** `string`: Optional override. Defaults based on provided media items. - one of: video, photo - **videoCoverTimestampMs** `integer`: 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. (min: 0) - **videoCoverImageUrl** `string`: 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** `integer`: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). (min: 0) - **autoAddMusic** `boolean`: When true, TikTok may add recommended music (photos only) - **videoMadeWithAi** `boolean`: Set true to disclose AI-generated content - **description** `string`: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. (max: 4000) ## FacebookPlatformData 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). Carousel posts (carouselCards) render a 2-5 card multi-link post, images only, mutually exclusive with story/reel. Geo-restriction is a hard visibility restriction: users outside the specified countries cannot see the post. Not supported for stories. ### Properties - **draft** `boolean`: 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** `string`: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. - one of: story, reel - **title** `string`: Reel title (only for contentType=reel). Separate from the caption/content field. - **firstComment** `string`: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true. - **pageId** `string`: 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**: No description - **carouselCards** `array`: Renders the post as a multi-link carousel (organic Page post). When set, mediaItems must be provided with the same length and all items must be images (no videos). Each cards[i] adds the click-through link and headline for the image at mediaItems[i]. Mutually exclusive with contentType=story|reel. Facebook display truncates name at ~35 chars and description at ~30 chars; longer strings are accepted but get truncated on render. - **carouselLink** `string`: Optional top-level "See more" destination shown on the carousel end card. Defaults to the first card's link when omitted. Only used together with carouselCards. ## PostUpdateResponse ### Properties - **message** `string`: No description - **post**: No description ## PostDeleteResponse ### Properties - **message** `string`: No description --- # List posts API Reference Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform. ## GET /v1/posts **List posts** Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform. ### Parameters - **undefined** (optional): No description - **undefined** (optional): No description - **status** (optional) in query: No description - **platform** (optional) in query: No description - **profileId** (optional) in query: No description - **createdBy** (optional) in query: No description - **dateFrom** (optional) in query: No description - **dateTo** (optional) in query: No description - **includeHidden** (optional) in query: No description - **search** (optional) in query: Search posts by text content. - **sortBy** (optional) in query: Sort order for results. - **accountId** (optional) in query: Filter posts to those published via a specific social account (24-char hex ObjectId). ### Responses #### 200: Paginated posts **Response Body:** - **posts** `array[Post]`: - **pagination**: `Pagination` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/posts **Create post** Create and optionally publish a post. Immediate posts (`publishNow: true`) include `platformPostUrl` in the response. Content is optional when media is attached or all platforms have `customContent`. See each platform's schema for media constraints. ## Idempotency Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post. **1. Same-request idempotency (5-minute window).** Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field — no new post is created. The official Zernio SDKs auto-generate a unique `x-request-id` per call. If you're using a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either: - Set a unique `x-request-id` per logical call (recommended — UUIDv4 is fine) - Or simply omit the header — we'll treat each request as new **Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node. **2. Content-hash dedup (24-hour window).** Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. Returns `error`, `accountId`, `platform`, and `existingPostId` so you can find the original. To intentionally re-post identical content within 24h, change something (the caption, the media, the account) — the dedup is keyed on the full content fingerprint. Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs. ### Parameters - **x-request-id** (optional) in header: Optional client-generated request identifier for safe retry (idempotency). When two requests carry the same value, the second is treated as a retry of the first and returns the original post (HTTP 200) instead of creating a duplicate. Window is ~5 minutes from the first request. Generate a UUID per logical call. SDKs do this automatically; HTTP clients should set it themselves or omit it. See the operation description for the full idempotency contract. ### Request Body - **title** `string`: No description - **content** `string`: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts. - **mediaItems** `array`: No description - **platforms** `array`: Target platforms and accounts for this post. Required for non-draft posts (returns 400 if empty). Drafts can omit platforms. - **scheduledFor** `string`: No description - **publishNow** `boolean`: No description - **isDraft** `boolean`: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically. - **timezone** `string`: No description - **tags** `array`: Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **crosspostingEnabled** `boolean`: No description - **metadata** `object`: No description - **tiktokSettings**: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **facebookSettings**: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **recycling**: No description - **queuedFromProfile** `string`: 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** `string`: Specific queue ID to use when scheduling via queue. Only used when queuedFromProfile is also provided. If omitted, uses the profile's default queue. ### Responses #### 201: Post created **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 400: Validation error **Response Body:** - **error** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden **Response Body:** - **error** `string`: No description #### 409: Duplicate content detected. Returned when the requested post matches an existing one on `(platform, accountId, content-hash)` within the last 24 hours, AND the request was NOT an `x-request-id` retry of an in-flight call. Distinct from same-`x-request-id` retries (which return HTTP 200 with the original post — see operation description for the idempotency contract). Body fields: - `error` — human-readable message - `details.accountId` — the account that already has this content - `details.platform` — the platform that already has this content - `details.existingPostId` — Zernio `_id` of the original post To intentionally re-post identical content within 24h, vary the content fingerprint (change the caption, swap a media item, or use a different account). To avoid 409s caused by retry loops, set a unique `x-request-id` per logical request — see `parameters.x-request-id` above. **Response Body:** - **error** `string`: No description (example: "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours.") - **details** `object`: - **accountId** `string`: No description - **platform** `string`: No description - **existingPostId** `string`: No description #### 429: Rate limit exceeded. Possible causes: API rate limit, velocity limit (15 posts/hour per account), account cooldown, or daily platform limits. **Response Body:** - **error** `string`: No description - **details** `object`: Additional context about the rate limit --- # Related Schema Definitions ## PostsListResponse ### Properties - **posts** `array`: No description - **pagination**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description ## Pagination ### Properties - **page** `integer`: No description - **limit** `integer`: No description - **total** `integer`: No description - **pages** `integer`: No description ## TikTokPlatformData 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** `boolean`: 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** `string`: One of the values returned by the TikTok creator info API for the account - **allowComment** `boolean`: Allow comments on the post - **allowDuet** `boolean`: Allow duets (required for video posts) - **allowStitch** `boolean`: Allow stitches (required for video posts) - **commercialContentType** `string`: Type of commercial content disclosure - one of: none, brand_organic, brand_content - **brandPartnerPromote** `boolean`: Whether the post promotes a brand partner - **isBrandOrganicPost** `boolean`: Whether the post is a brand organic post - **contentPreviewConfirmed** `boolean`: User has confirmed they previewed the content - **expressConsentGiven** `boolean`: User has given express consent for posting - **mediaType** `string`: Optional override. Defaults based on provided media items. - one of: video, photo - **videoCoverTimestampMs** `integer`: 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. (min: 0) - **videoCoverImageUrl** `string`: 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** `integer`: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). (min: 0) - **autoAddMusic** `boolean`: When true, TikTok may add recommended music (photos only) - **videoMadeWithAi** `boolean`: Set true to disclose AI-generated content - **description** `string`: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. (max: 4000) ## FacebookPlatformData 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). Carousel posts (carouselCards) render a 2-5 card multi-link post, images only, mutually exclusive with story/reel. Geo-restriction is a hard visibility restriction: users outside the specified countries cannot see the post. Not supported for stories. ### Properties - **draft** `boolean`: 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** `string`: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. - one of: story, reel - **title** `string`: Reel title (only for contentType=reel). Separate from the caption/content field. - **firstComment** `string`: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true. - **pageId** `string`: 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**: No description - **carouselCards** `array`: Renders the post as a multi-link carousel (organic Page post). When set, mediaItems must be provided with the same length and all items must be images (no videos). Each cards[i] adds the click-through link and headline for the image at mediaItems[i]. Mutually exclusive with contentType=story|reel. Facebook display truncates name at ~35 chars and description at ~30 chars; longer strings are accepted but get truncated on render. - **carouselLink** `string`: Optional top-level "See more" destination shown on the carousel end card. Defaults to the first card's link when omitted. Only used together with carouselCards. ## PostCreateResponse ### Properties - **message** `string`: No description - **post**: No description --- # Retry failed post API Reference Immediately retries publishing a failed post. Returns the updated post with its new status. ## POST /v1/posts/{postId}/retry **Retry failed post** Immediately retries publishing a failed post. Returns the updated post with its new status. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Retry successful **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 207: Partial success #### 400: Invalid state #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 409: Post is currently publishing #### 429: 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). **Response Body:** - **error** `string`: No description - **details** `object`: No description --- # Related Schema Definitions ## PostRetryResponse ### Properties - **message** `string`: No description - **post**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Unpublish post API Reference 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. ## POST /v1/posts/{postId}/unpublish **Unpublish post** 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 - **postId** (required) in path: No description ### Request Body - **platform** (required) `string`: The platform to delete the post from - one of: threads, facebook, twitter, linkedin, youtube, pinterest, reddit, bluesky, googlebusiness, telegram ### Responses #### 200: Post deleted from platform **Response Body:** - **success** `boolean`: No description - **message** `string`: No description #### 400: Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Platform API deletion failed --- --- # Update post metadata API Reference 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. ## POST /v1/posts/{postId}/update-metadata **Update post metadata** 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 - **postId** (required) in path: Zernio post ID, or "_" when using direct video ID mode ### Request Body - **platform** (required) `string`: The platform to update metadata on - one of: youtube - **videoId** `string`: YouTube video ID (required for direct mode, ignored for post-based mode) - **accountId** `string`: Zernio social account ID (required for direct mode, ignored for post-based mode) - **title** `string`: New video title (max 100 characters for YouTube) - **description** `string`: New video description - **tags** `array`: Array of keyword tags (max 500 characters combined for YouTube) - **categoryId** `string`: YouTube video category ID - **privacyStatus** `string`: Video privacy setting - one of: public, private, unlisted - **thumbnailUrl** `string`: 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** `boolean`: COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). - **containsSyntheticMedia** `boolean`: AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label. - **playlistId** `string`: 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. ### Responses #### 200: Metadata updated successfully **Response Body:** - **success** `boolean`: No description - **message** `string`: No description - **videoId** `string`: Only present in direct video ID mode - **updatedFields** `array[string]`: #### 400: Invalid request: unsupported platform, post not published, missing fields, or validation error. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") #### 500: Platform API update failed --- --- # Update post API Reference Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited. Published posts can only have their recycling config updated. ## GET /v1/posts/{postId} **Get post** Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Post **Response Body:** - **post**: `Post` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/posts/{postId} **Update post** Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited. Published posts can only have their recycling config updated. ### Parameters - **postId** (required) in path: No description ### Request Body - **title** `string`: No description - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: Target platforms and accounts for this post. Each item must include platform and accountId. - **scheduledFor** `string`: No description - **publishNow** `boolean`: No description - **isDraft** `boolean`: No description - **timezone** `string`: No description - **visibility** `string`: No description - one of: public, private, unlisted - **tags** `array`: No description - **hashtags** `array`: No description - **mentions** `array`: No description - **crosspostingEnabled** `boolean`: No description - **metadata** `object`: No description - **queuedFromProfile** `string`: Profile ID to schedule via queue. - **queueId** `string`: Specific queue ID to use when scheduling via queue. - **tiktokSettings**: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **facebookSettings**: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - **recycling**: No description ### Responses #### 200: Post updated **Response Body:** - **message** `string`: No description - **post**: `Post` - See schema definition #### 207: Partial publish success #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/posts/{postId} **Delete post** Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded. ### Parameters - **postId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Cannot delete published posts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## PostGetResponse ### Properties - **post**: No description ## Post ### Properties - **_id** `string`: No description - **userId**: No description - **title** `string`: YouTube: title must be ≤ 100 characters. - **content** `string`: No description - **mediaItems** `array`: No description - **platforms** `array`: No description - **scheduledFor** `string`: No description - **timezone** `string`: No description - **status** `string`: No description - one of: draft, scheduled, publishing, published, failed, partial - **tags** `array`: YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed. - **hashtags** `array`: No description - **mentions** `array`: 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. - **visibility** `string`: No description - one of: public, private, unlisted - **metadata** `object`: No description - **recycling**: No description - **recycledFromPostId** `string`: ID of the original post if this post was created via recycling - **queuedFromProfile** `string`: Profile ID if the post was scheduled via the queue - **queueId** `string`: Queue ID if the post was scheduled via a specific queue - **createdAt** `string`: No description - **updatedAt** `string`: No description ## TikTokPlatformData 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** `boolean`: 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** `string`: One of the values returned by the TikTok creator info API for the account - **allowComment** `boolean`: Allow comments on the post - **allowDuet** `boolean`: Allow duets (required for video posts) - **allowStitch** `boolean`: Allow stitches (required for video posts) - **commercialContentType** `string`: Type of commercial content disclosure - one of: none, brand_organic, brand_content - **brandPartnerPromote** `boolean`: Whether the post promotes a brand partner - **isBrandOrganicPost** `boolean`: Whether the post is a brand organic post - **contentPreviewConfirmed** `boolean`: User has confirmed they previewed the content - **expressConsentGiven** `boolean`: User has given express consent for posting - **mediaType** `string`: Optional override. Defaults based on provided media items. - one of: video, photo - **videoCoverTimestampMs** `integer`: 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. (min: 0) - **videoCoverImageUrl** `string`: 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** `integer`: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image). (min: 0) - **autoAddMusic** `boolean`: When true, TikTok may add recommended music (photos only) - **videoMadeWithAi** `boolean`: Set true to disclose AI-generated content - **description** `string`: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. (max: 4000) ## FacebookPlatformData 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). Carousel posts (carouselCards) render a 2-5 card multi-link post, images only, mutually exclusive with story/reel. Geo-restriction is a hard visibility restriction: users outside the specified countries cannot see the post. Not supported for stories. ### Properties - **draft** `boolean`: 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** `string`: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted. - one of: story, reel - **title** `string`: Reel title (only for contentType=reel). Separate from the caption/content field. - **firstComment** `string`: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true. - **pageId** `string`: 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**: No description - **carouselCards** `array`: Renders the post as a multi-link carousel (organic Page post). When set, mediaItems must be provided with the same length and all items must be images (no videos). Each cards[i] adds the click-through link and headline for the image at mediaItems[i]. Mutually exclusive with contentType=story|reel. Facebook display truncates name at ~35 chars and description at ~30 chars; longer strings are accepted but get truncated on render. - **carouselLink** `string`: Optional top-level "See more" destination shown on the carousel end card. Defaults to the first card's link when omitted. Only used together with carouselCards. ## PostUpdateResponse ### Properties - **message** `string`: No description - **post**: No description ## PostDeleteResponse ### Properties - **message** `string`: No description --- # Create profile API Reference Creates a new profile with a name, optional description, and color. ## GET /v1/profiles **List profiles** Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit. ### Parameters - **includeOverLimit** (optional) in query: When true, includes over-limit profiles (marked with isOverLimit: true). ### Responses #### 200: Profiles **Response Body:** - **profiles** `array[Profile]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/profiles **Create profile** Creates a new profile with a name, optional description, and color. ### Request Body - **name** (required) `string`: No description - **description** `string`: No description - **color** `string`: No description ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **profile**: `Profile` - See schema definition #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Payment method or enterprise contract required. The authenticated account hit a billing gate before the connection could proceed. Three reasons: - `free_tier_exceeded`: the team has connected more accounts than the free tier allows. Add a payment method on the dashboard to continue (the user will be billed per additional connected account). - `twitter_passthrough`: connecting an X (Twitter) account requires a card on file from day one because X API calls incur real per-call pass-through costs. Applies to the 1st X account, not just the 3rd+. - `enterprise_required`: the team has reached the public pricing ceiling (2,000 connected accounts). Beyond that requires a custom enterprise contract negotiated directly. `dashboard_url` deep-links to the enterprise contact page rather than the billing tab. The end-user already has a card on file; this gate is about contract terms, not card collection. SDK consumers should switch on `reason` to render the right prompt. For `free_tier_exceeded` and `twitter_passthrough`, redirect the end-user to `dashboard_url` to add a payment method via Zernio's hosted Stripe Setup Checkout. For `enterprise_required`, redirect to `dashboard_url` (the enterprise contact form) so they can talk to sales. **Response Body:** - **error** (required) `string`: Human-readable error message suitable for end-user display. (example: "X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.") - **code** (required) `string`: Machine-readable error code. Stable across versions. - one of: PAYMENT_REQUIRED - **reason** (required) `string`: Discriminator for which gate fired. - one of: free_tier_exceeded, twitter_passthrough, enterprise_required - **documentation_url** `string` (uri): Link to the relevant documentation page. (example: "https://docs.zernio.com/billing/payment-method-required") - **dashboard_url** `string` (uri): Deep-link to send the end-user to. For `free_tier_exceeded` and `twitter_passthrough` this is the Zernio billing tab. For `enterprise_required` this is the Zernio enterprise contact page. (example: "https://zernio.com/dashboard?tab=billing") - **details** `object`: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`. - **free_tier_account_limit** `integer`: How many accounts the free tier allows. Only set when reason=free_tier_exceeded. (example: 2) - **current_account_count** `integer`: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required. (example: 5) - **has_payment_method** `boolean`: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough. - **public_account_limit** `integer`: Public pricing ceiling (the published cap beyond which an enterprise contract is required). Only set when reason=enterprise_required. (example: 2000) - **effective_account_limit** `integer`: The cap actually applied to this team. Equals `public_account_limit` for organic teams; for teams with a per-customer override (grandfathered legacy customers, signed enterprise contracts) this can be higher. Only set when reason=enterprise_required. (example: 2000) #### 403: Profile limit exceeded --- # Related Schema Definitions ## ProfilesListResponse ### Properties - **profiles** `array`: No description ## Profile ### Properties - **_id** `string`: No description - **userId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description - **isOverLimit** `boolean`: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. - **createdAt** `string`: No description ## ProfileCreateResponse ### Properties - **message** `string`: No description - **profile**: No description --- # Delete profile API Reference Permanently deletes a profile by ID. ## GET /v1/profiles/{profileId} **Get profile** Returns a single profile by ID, including its name, color, and default status. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Profile **Response Body:** - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/profiles/{profileId} **Update profile** Updates a profile's name, description, color, or default status. ### Parameters - **profileId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/profiles/{profileId} **Delete profile** Permanently deletes a profile by ID. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Has connected accounts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Profile ### Properties - **_id** `string`: No description - **userId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description - **isOverLimit** `boolean`: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. - **createdAt** `string`: No description --- # Get profile API Reference Returns a single profile by ID, including its name, color, and default status. ## GET /v1/profiles/{profileId} **Get profile** Returns a single profile by ID, including its name, color, and default status. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Profile **Response Body:** - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/profiles/{profileId} **Update profile** Updates a profile's name, description, color, or default status. ### Parameters - **profileId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/profiles/{profileId} **Delete profile** Permanently deletes a profile by ID. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Has connected accounts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Profile ### Properties - **_id** `string`: No description - **userId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description - **isOverLimit** `boolean`: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. - **createdAt** `string`: No description --- # List profiles API Reference Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit. ## GET /v1/profiles **List profiles** Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit. ### Parameters - **includeOverLimit** (optional) in query: When true, includes over-limit profiles (marked with isOverLimit: true). ### Responses #### 200: Profiles **Response Body:** - **profiles** `array[Profile]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/profiles **Create profile** Creates a new profile with a name, optional description, and color. ### Request Body - **name** (required) `string`: No description - **description** `string`: No description - **color** `string`: No description ### Responses #### 201: Created **Response Body:** - **message** `string`: No description - **profile**: `Profile` - See schema definition #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 402: Payment method or enterprise contract required. The authenticated account hit a billing gate before the connection could proceed. Three reasons: - `free_tier_exceeded`: the team has connected more accounts than the free tier allows. Add a payment method on the dashboard to continue (the user will be billed per additional connected account). - `twitter_passthrough`: connecting an X (Twitter) account requires a card on file from day one because X API calls incur real per-call pass-through costs. Applies to the 1st X account, not just the 3rd+. - `enterprise_required`: the team has reached the public pricing ceiling (2,000 connected accounts). Beyond that requires a custom enterprise contract negotiated directly. `dashboard_url` deep-links to the enterprise contact page rather than the billing tab. The end-user already has a card on file; this gate is about contract terms, not card collection. SDK consumers should switch on `reason` to render the right prompt. For `free_tier_exceeded` and `twitter_passthrough`, redirect the end-user to `dashboard_url` to add a payment method via Zernio's hosted Stripe Setup Checkout. For `enterprise_required`, redirect to `dashboard_url` (the enterprise contact form) so they can talk to sales. **Response Body:** - **error** (required) `string`: Human-readable error message suitable for end-user display. (example: "X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.") - **code** (required) `string`: Machine-readable error code. Stable across versions. - one of: PAYMENT_REQUIRED - **reason** (required) `string`: Discriminator for which gate fired. - one of: free_tier_exceeded, twitter_passthrough, enterprise_required - **documentation_url** `string` (uri): Link to the relevant documentation page. (example: "https://docs.zernio.com/billing/payment-method-required") - **dashboard_url** `string` (uri): Deep-link to send the end-user to. For `free_tier_exceeded` and `twitter_passthrough` this is the Zernio billing tab. For `enterprise_required` this is the Zernio enterprise contact page. (example: "https://zernio.com/dashboard?tab=billing") - **details** `object`: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`. - **free_tier_account_limit** `integer`: How many accounts the free tier allows. Only set when reason=free_tier_exceeded. (example: 2) - **current_account_count** `integer`: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required. (example: 5) - **has_payment_method** `boolean`: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough. - **public_account_limit** `integer`: Public pricing ceiling (the published cap beyond which an enterprise contract is required). Only set when reason=enterprise_required. (example: 2000) - **effective_account_limit** `integer`: The cap actually applied to this team. Equals `public_account_limit` for organic teams; for teams with a per-customer override (grandfathered legacy customers, signed enterprise contracts) this can be higher. Only set when reason=enterprise_required. (example: 2000) #### 403: Profile limit exceeded --- # Related Schema Definitions ## ProfilesListResponse ### Properties - **profiles** `array`: No description ## Profile ### Properties - **_id** `string`: No description - **userId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description - **isOverLimit** `boolean`: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. - **createdAt** `string`: No description ## ProfileCreateResponse ### Properties - **message** `string`: No description - **profile**: No description --- # Update profile API Reference Updates a profile's name, description, color, or default status. ## GET /v1/profiles/{profileId} **Get profile** Returns a single profile by ID, including its name, color, and default status. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Profile **Response Body:** - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PUT /v1/profiles/{profileId} **Update profile** Updates a profile's name, description, color, or default status. ### Parameters - **profileId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description ### Responses #### 200: Updated **Response Body:** - **message** `string`: No description - **profile**: `Profile` - See schema definition #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/profiles/{profileId} **Delete profile** Permanently deletes a profile by ID. ### Parameters - **profileId** (required) in path: No description ### Responses #### 200: Deleted **Response Body:** - **message** `string`: No description #### 400: Has connected accounts #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Forbidden #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- # Related Schema Definitions ## Profile ### Properties - **_id** `string`: No description - **userId** `string`: No description - **name** `string`: No description - **description** `string`: No description - **color** `string`: No description - **isDefault** `boolean`: No description - **isOverLimit** `boolean`: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit. - **createdAt** `string`: No description --- # Create schedule API Reference Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. ## GET /v1/queue/slots **List schedules** Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. ### Parameters - **profileId** (required) in query: Profile ID to get queues for - **queueId** (optional) in query: Specific queue ID to retrieve (optional) - **all** (optional) in query: Set to 'true' to list all queues for the profile ### Responses #### 200: Queue schedule(s) retrieved **Response Body:** *One of the following:* - **exists** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **queues** `array[QueueSchedule]`: - **count** `integer`: No description #### 400: Missing profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## POST /v1/queue/slots **Create schedule** Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. ### Request Body - **profileId** (required) `string`: Profile ID - **name** (required) `string`: Queue name (e.g., Evening Posts) - **timezone** (required) `string`: IANA timezone - **slots** (required) `array`: No description - **active** `boolean`: No description ### Responses #### 201: Queue created **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: #### 400: Invalid request or validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## PUT /v1/queue/slots **Update schedule** 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. ### Request Body - **profileId** (required) `string`: No description - **queueId** `string`: Queue ID to update (optional) - **name** `string`: Queue name - **timezone** (required) `string`: No description - **slots** (required) `array`: No description - **active** `boolean`: No description - **setAsDefault** `boolean`: Make this queue the default - **reshuffleExisting** `boolean`: Whether to reschedule existing queued posts to match new slots ### Responses #### 200: Queue schedule updated **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **reshuffledCount** `integer`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## DELETE /v1/queue/slots **Delete schedule** 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 - **profileId** (required) in query: No description - **queueId** (required) in query: Queue ID to delete ### Responses #### 200: Queue schedule deleted **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description #### 400: Missing profileId or queueId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## QueueSchedule ### Properties - **_id** `string`: Unique queue identifier - **profileId** `string`: Profile ID this queue belongs to - **name** `string`: Queue name (e.g., "Morning Posts", "Evening Content") - **timezone** `string`: IANA timezone (e.g., America/New_York) - **slots** `array`: No description - **active** `boolean`: Whether the queue is active - **isDefault** `boolean`: Whether this is the default queue for the profile (used when no queueId specified) - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Delete schedule API Reference 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. ## GET /v1/queue/slots **List schedules** Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. ### Parameters - **profileId** (required) in query: Profile ID to get queues for - **queueId** (optional) in query: Specific queue ID to retrieve (optional) - **all** (optional) in query: Set to 'true' to list all queues for the profile ### Responses #### 200: Queue schedule(s) retrieved **Response Body:** *One of the following:* - **exists** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **queues** `array[QueueSchedule]`: - **count** `integer`: No description #### 400: Missing profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## POST /v1/queue/slots **Create schedule** Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. ### Request Body - **profileId** (required) `string`: Profile ID - **name** (required) `string`: Queue name (e.g., Evening Posts) - **timezone** (required) `string`: IANA timezone - **slots** (required) `array`: No description - **active** `boolean`: No description ### Responses #### 201: Queue created **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: #### 400: Invalid request or validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## PUT /v1/queue/slots **Update schedule** 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. ### Request Body - **profileId** (required) `string`: No description - **queueId** `string`: Queue ID to update (optional) - **name** `string`: Queue name - **timezone** (required) `string`: No description - **slots** (required) `array`: No description - **active** `boolean`: No description - **setAsDefault** `boolean`: Make this queue the default - **reshuffleExisting** `boolean`: Whether to reschedule existing queued posts to match new slots ### Responses #### 200: Queue schedule updated **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **reshuffledCount** `integer`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## DELETE /v1/queue/slots **Delete schedule** 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 - **profileId** (required) in query: No description - **queueId** (required) in query: Queue ID to delete ### Responses #### 200: Queue schedule deleted **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description #### 400: Missing profileId or queueId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## QueueSchedule ### Properties - **_id** `string`: Unique queue identifier - **profileId** `string`: Profile ID this queue belongs to - **name** `string`: Queue name (e.g., "Morning Posts", "Evening Content") - **timezone** `string`: IANA timezone (e.g., America/New_York) - **slots** `array`: No description - **active** `boolean`: Whether the queue is active - **isDefault** `boolean`: Whether this is the default queue for the profile (used when no queueId specified) - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Get next available slot API Reference Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor. ## GET /v1/queue/next-slot **Get next available slot** Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor. ### Parameters - **profileId** (required) in query: No description - **queueId** (optional) in query: Specific queue ID (optional, defaults to profile's default queue) ### Responses #### 200: Next available slot **Response Body:** - **profileId** `string`: No description - **nextSlot** `string` (date-time): No description - **timezone** `string`: No description - **queueId** `string`: Queue ID this slot belongs to - **queueName** `string`: Queue name #### 400: Invalid parameters or inactive queue #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile or queue schedule not found, or no available slots --- --- # List schedules API Reference Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. ## GET /v1/queue/slots **List schedules** Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. ### Parameters - **profileId** (required) in query: Profile ID to get queues for - **queueId** (optional) in query: Specific queue ID to retrieve (optional) - **all** (optional) in query: Set to 'true' to list all queues for the profile ### Responses #### 200: Queue schedule(s) retrieved **Response Body:** *One of the following:* - **exists** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **queues** `array[QueueSchedule]`: - **count** `integer`: No description #### 400: Missing profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## POST /v1/queue/slots **Create schedule** Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. ### Request Body - **profileId** (required) `string`: Profile ID - **name** (required) `string`: Queue name (e.g., Evening Posts) - **timezone** (required) `string`: IANA timezone - **slots** (required) `array`: No description - **active** `boolean`: No description ### Responses #### 201: Queue created **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: #### 400: Invalid request or validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## PUT /v1/queue/slots **Update schedule** 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. ### Request Body - **profileId** (required) `string`: No description - **queueId** `string`: Queue ID to update (optional) - **name** `string`: Queue name - **timezone** (required) `string`: No description - **slots** (required) `array`: No description - **active** `boolean`: No description - **setAsDefault** `boolean`: Make this queue the default - **reshuffleExisting** `boolean`: Whether to reschedule existing queued posts to match new slots ### Responses #### 200: Queue schedule updated **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **reshuffledCount** `integer`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## DELETE /v1/queue/slots **Delete schedule** 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 - **profileId** (required) in query: No description - **queueId** (required) in query: Queue ID to delete ### Responses #### 200: Queue schedule deleted **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description #### 400: Missing profileId or queueId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## QueueSchedule ### Properties - **_id** `string`: Unique queue identifier - **profileId** `string`: Profile ID this queue belongs to - **name** `string`: Queue name (e.g., "Morning Posts", "Evening Content") - **timezone** `string`: IANA timezone (e.g., America/New_York) - **slots** `array`: No description - **active** `boolean`: Whether the queue is active - **isDefault** `boolean`: Whether this is the default queue for the profile (used when no queueId specified) - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Preview upcoming slots API Reference Returns the next N upcoming queue slot times for a profile as ISO datetime strings. ## GET /v1/queue/preview **Preview upcoming slots** Returns the next N upcoming queue slot times for a profile as ISO datetime strings. ### Parameters - **profileId** (required) in query: No description - **queueId** (optional) in query: Filter by specific queue ID. Omit to use the default queue. - **count** (optional) in query: No description ### Responses #### 200: Queue slots preview **Response Body:** - **profileId** `string`: No description - **count** `integer`: No description - **slots** `array[string]`: #### 400: Invalid parameters #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile or queue schedule not found --- --- # Update schedule API Reference 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. ## GET /v1/queue/slots **List schedules** Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue. ### Parameters - **profileId** (required) in query: Profile ID to get queues for - **queueId** (optional) in query: Specific queue ID to retrieve (optional) - **all** (optional) in query: Set to 'true' to list all queues for the profile ### Responses #### 200: Queue schedule(s) retrieved **Response Body:** *One of the following:* - **exists** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **queues** `array[QueueSchedule]`: - **count** `integer`: No description #### 400: Missing profileId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## POST /v1/queue/slots **Create schedule** Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. ### Request Body - **profileId** (required) `string`: Profile ID - **name** (required) `string`: Queue name (e.g., Evening Posts) - **timezone** (required) `string`: IANA timezone - **slots** (required) `array`: No description - **active** `boolean`: No description ### Responses #### 201: Queue created **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: #### 400: Invalid request or validation error #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## PUT /v1/queue/slots **Update schedule** 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. ### Request Body - **profileId** (required) `string`: No description - **queueId** `string`: Queue ID to update (optional) - **name** `string`: Queue name - **timezone** (required) `string`: No description - **slots** (required) `array`: No description - **active** `boolean`: No description - **setAsDefault** `boolean`: Make this queue the default - **reshuffleExisting** `boolean`: Whether to reschedule existing queued posts to match new slots ### Responses #### 200: Queue schedule updated **Response Body:** - **success** `boolean`: No description - **schedule**: `QueueSchedule` - See schema definition - **nextSlots** `array[string]`: - **reshuffledCount** `integer`: No description #### 400: Invalid request #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Profile not found --- ## DELETE /v1/queue/slots **Delete schedule** 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 - **profileId** (required) in query: No description - **queueId** (required) in query: Queue ID to delete ### Responses #### 200: Queue schedule deleted **Response Body:** - **success** `boolean`: No description - **deleted** `boolean`: No description #### 400: Missing profileId or queueId #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- # Related Schema Definitions ## QueueSchedule ### Properties - **_id** `string`: Unique queue identifier - **profileId** `string`: Profile ID this queue belongs to - **name** `string`: Queue name (e.g., "Morning Posts", "Evening Content") - **timezone** `string`: IANA timezone (e.g., America/New_York) - **slots** `array`: No description - **active** `boolean`: Whether the queue is active - **isDefault** `boolean`: Whether this is the default queue for the profile (used when no queueId specified) - **createdAt** `string`: No description - **updatedAt** `string`: No description --- # Get subreddit feed API Reference Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination. ## GET /v1/reddit/feed **Get subreddit feed** Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination. ### Parameters - **accountId** (required) in query: No description - **subreddit** (optional) in query: No description - **sort** (optional) in query: No description - **limit** (optional) in query: No description - **after** (optional) in query: No description - **t** (optional) in query: No description ### Responses #### 200: Feed items **Response Body:** - **items** `array[RedditPost]`: - **after** `string`: No description - **before** `string`: No description #### 400: Missing params #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- # Related Schema Definitions ## RedditPost A normalized Reddit post returned by the feed and search endpoints ### Properties - **id** `string`: Reddit post ID (without type prefix) - **fullname** `string`: Reddit fullname (e.g. t3_abc123) - **title** `string`: No description - **author** `string`: No description - **subreddit** `string`: No description - **url** `string`: Post URL (may be a gallery URL - **permalink** `string`: Full permalink to the Reddit post - **selftext** `string`: Self-post body text (empty string for link posts) - **createdUtc** `number`: Unix timestamp of post creation - **score** `integer`: No description - **numComments** `integer`: No description - **over18** `boolean`: Whether the post is marked NSFW - **stickied** `boolean`: No description - **flairText** `string`: Link flair text if set - **isGallery** `boolean`: Whether the post is a gallery with multiple images - **galleryImages** `array`: Individual image URLs for gallery posts (only present when isGallery is true) --- # Search posts API Reference Search Reddit posts using a connected account. Optionally scope to a specific subreddit. ## GET /v1/reddit/search **Search posts** Search Reddit posts using a connected account. Optionally scope to a specific subreddit. ### Parameters - **accountId** (required) in query: No description - **subreddit** (optional) in query: No description - **q** (required) in query: No description - **restrict_sr** (optional) in query: No description - **sort** (optional) in query: No description - **limit** (optional) in query: No description - **after** (optional) in query: No description ### Responses #### 200: Search results **Response Body:** - **items** `array[RedditPost]`: - **after** `string`: No description - **before** `string`: No description #### 400: Missing params #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Account not found --- # Related Schema Definitions ## RedditPost A normalized Reddit post returned by the feed and search endpoints ### Properties - **id** `string`: Reddit post ID (without type prefix) - **fullname** `string`: Reddit fullname (e.g. t3_abc123) - **title** `string`: No description - **author** `string`: No description - **subreddit** `string`: No description - **url** `string`: Post URL (may be a gallery URL - **permalink** `string`: Full permalink to the Reddit post - **selftext** `string`: Self-post body text (empty string for link posts) - **createdUtc** `number`: Unix timestamp of post creation - **score** `integer`: No description - **numComments** `integer`: No description - **over18** `boolean`: Whether the post is marked NSFW - **stickied** `boolean`: No description - **flairText** `string`: Link flair text if set - **isGallery** `boolean`: Whether the post is a gallery with multiple images - **galleryImages** `array`: Individual image URLs for gallery posts (only present when isGallery is true) --- # Delete review reply API Reference Delete a reply to a review (Google Business only). Requires accountId in request body. ## POST /v1/inbox/reviews/{reviewId}/reply **Reply to review** Post a reply to a review. Requires accountId in request body. ### Parameters - **reviewId** (required) in path: Review ID (URL-encoded for Google Business) ### Request Body - **accountId** (required) `string`: No description - **message** (required) `string`: No description ### Responses #### 200: Reply posted **Response Body:** - **status** `string`: No description - **reply** `object`: - **id** `string`: No description - **text** `string`: No description - **created** `string` (date-time): No description - **platform** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/reviews/{reviewId}/reply **Delete review reply** Delete a reply to a review (Google Business only). Requires accountId in request body. ### Parameters - **reviewId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description ### Responses #### 200: Reply deleted **Response Body:** - **status** `string`: No description - **message** `string`: No description - **platform** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # List reviews API Reference Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options. Supported platforms: Facebook, Google Business. ## GET /v1/inbox/reviews **List reviews** Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options. Supported platforms: Facebook, Google Business. ### Parameters - **profileId** (optional) in query: No description - **platform** (optional) in query: No description - **minRating** (optional) in query: No description - **maxRating** (optional) in query: No description - **hasReply** (optional) in query: Filter by reply status - **sortBy** (optional) in query: No description - **sortOrder** (optional) in query: No description - **limit** (optional) in query: No description - **cursor** (optional) in query: No description - **accountId** (optional) in query: Filter by specific social account ID ### Responses #### 200: Aggregated reviews **Response Body:** - **status** `string`: No description - **data** `array[object]`: - **id** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountUsername** `string`: No description - **reviewer** `object`: - **id** `string`: No description - **name** `string`: No description - **profileImage** `string`: No description - **rating** `integer`: No description - **text** `string`: No description - **created** `string` (date-time): No description - **hasReply** `boolean`: No description - **reply** `object`: - **id** `string`: No description - **text** `string`: No description - **created** `string` (date-time): No description - **reviewUrl** `string`: No description - **pagination** `object`: - **hasMore** `boolean`: No description - **nextCursor** `string`: No description - **meta** `object`: - **accountsQueried** `integer`: No description - **accountsFailed** `integer`: No description - **failedAccounts** `array[object]`: - **accountId** `string`: No description - **accountUsername** `string`: No description - **platform** `string`: No description - **error** `string`: No description - **code** `string`: Error code if available - **retryAfter** `integer`: Seconds to wait before retry (rate limits) - **lastUpdated** `string` (date-time): No description - **summary** `object`: - **totalReviews** `integer`: No description - **averageRating** `number`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Reply to review API Reference Post a reply to a review. Requires accountId in request body. ## POST /v1/inbox/reviews/{reviewId}/reply **Reply to review** Post a reply to a review. Requires accountId in request body. ### Parameters - **reviewId** (required) in path: Review ID (URL-encoded for Google Business) ### Request Body - **accountId** (required) `string`: No description - **message** (required) `string`: No description ### Responses #### 200: Reply posted **Response Body:** - **status** `string`: No description - **reply** `object`: - **id** `string`: No description - **text** `string`: No description - **created** `string` (date-time): No description - **platform** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- ## DELETE /v1/inbox/reviews/{reviewId}/reply **Delete review reply** Delete a reply to a review (Google Business only). Requires accountId in request body. ### Parameters - **reviewId** (required) in path: No description ### Request Body - **accountId** (required) `string`: No description ### Responses #### 200: Reply deleted **Response Body:** - **status** `string`: No description - **message** `string`: No description - **platform** `string`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Inbox addon required --- --- # Activate sequence API Reference Start a draft or paused sequence. The sequence must have at least one step. ## POST /v1/sequences/{sequenceId}/activate **Activate sequence** Start a draft or paused sequence. The sequence must have at least one step. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence activated **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **status** `string`: No description #### 400: Invalid status or no steps #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Create sequence API Reference Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template. ## GET /v1/sequences **List sequences** Returns sequences with enrollment stats. Filter by status, platform, or profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **status** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Sequences list **Response Body:** - **success** `boolean`: No description - **sequences** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountName** `string`: Display name of the sending account - **messagePreview** `string`: First step template name or message text snippet - **status** `string`: No description - one of: draft, active, paused - **stepsCount** `integer`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **totalEnrolled** `integer`: No description - **totalCompleted** `integer`: No description - **totalExited** `integer`: No description - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/sequences **Create sequence** Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: No description - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp - **name** (required) `string`: No description - **description** `string`: No description - **steps** `array`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description ### Responses #### 200: Sequence created **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **status** `string`: No description - **stepsCount** `integer`: No description - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Delete sequence API Reference Permanently delete a sequence. Active enrollments are stopped. ## GET /v1/sequences/{sequenceId} **Get sequence with steps** Returns a sequence with all its steps and enrollment stats. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence details with steps **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - one of: draft, active, paused - **steps** `array[object]`: - **order** `integer`: No description - **delayMinutes** `integer`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **variableMapping** `object`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **totalEnrolled** `integer`: No description - **totalCompleted** `integer`: No description - **totalExited** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/sequences/{sequenceId} **Update sequence** Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused. ### Parameters - **sequenceId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **steps** `array`: Replace the full step list. Only allowed while the sequence is draft or paused. - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description ### Responses #### 200: Sequence updated **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **steps** `array[object]`: Type: `object` - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/sequences/{sequenceId} **Delete sequence** Permanently delete a sequence. Active enrollments are stopped. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Enroll contacts in a sequence API Reference Enroll one or more contacts into a sequence. Contacts already enrolled are skipped. ## POST /v1/sequences/{sequenceId}/enroll **Enroll contacts in a sequence** Enroll one or more contacts into a sequence. Contacts already enrolled are skipped. ### Parameters - **sequenceId** (required) in path: No description ### Request Body - **contactIds** (required) `array`: No description - **channelIds** `array`: Optional. Auto-detected if not provided. ### Responses #### 200: Enrollment results **Response Body:** - **success** `boolean`: No description - **enrolled** `integer`: Number of contacts successfully enrolled - **skipped** `integer`: Number skipped (already enrolled or missing channel) #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Get sequence with steps API Reference Returns a sequence with all its steps and enrollment stats. ## GET /v1/sequences/{sequenceId} **Get sequence with steps** Returns a sequence with all its steps and enrollment stats. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence details with steps **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - one of: draft, active, paused - **steps** `array[object]`: - **order** `integer`: No description - **delayMinutes** `integer`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **variableMapping** `object`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **totalEnrolled** `integer`: No description - **totalCompleted** `integer`: No description - **totalExited** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/sequences/{sequenceId} **Update sequence** Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused. ### Parameters - **sequenceId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **steps** `array`: Replace the full step list. Only allowed while the sequence is draft or paused. - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description ### Responses #### 200: Sequence updated **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **steps** `array[object]`: Type: `object` - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/sequences/{sequenceId} **Delete sequence** Permanently delete a sequence. Active enrollments are stopped. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List enrollments for a sequence API Reference Returns enrolled contacts with their progress, status, and next scheduled step. ## GET /v1/sequences/{sequenceId}/enrollments **List enrollments for a sequence** Returns enrolled contacts with their progress, status, and next scheduled step. ### Parameters - **sequenceId** (required) in path: No description - **status** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Enrollments list with progress **Response Body:** - **success** `boolean`: No description - **enrollments** `array[object]`: - **id** `string`: No description - **contactId** `string`: No description - **channelId** `string`: No description - **platformIdentifier** `string`: No description - **contactName** `string`: No description - **currentStepIndex** `integer`: No description - **status** `string`: No description - one of: active, completed, exited, paused - **exitReason** `string`: No description - **nextStepAt** `string` (date-time): No description - **stepsSent** `integer`: No description - **lastStepSentAt** `string` (date-time): No description - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # List sequences API Reference Returns sequences with enrollment stats. Filter by status, platform, or profile. ## GET /v1/sequences **List sequences** Returns sequences with enrollment stats. Filter by status, platform, or profile. ### Parameters - **profileId** (optional) in query: Filter by profile. Omit to list across all profiles - **status** (optional) in query: No description - **limit** (optional) in query: No description - **skip** (optional) in query: No description ### Responses #### 200: Sequences list **Response Body:** - **success** `boolean`: No description - **sequences** `array[object]`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **accountName** `string`: Display name of the sending account - **messagePreview** `string`: First step template name or message text snippet - **status** `string`: No description - one of: draft, active, paused - **stepsCount** `integer`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **totalEnrolled** `integer`: No description - **totalCompleted** `integer`: No description - **totalExited** `integer`: No description - **createdAt** `string` (date-time): No description - **pagination** `object`: - **total** `integer`: No description - **limit** `integer`: No description - **skip** `integer`: No description - **hasMore** `boolean`: No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- ## POST /v1/sequences **Create sequence** Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template. ### Request Body - **profileId** (required) `string`: No description - **accountId** (required) `string`: No description - **platform** (required) `string`: No description - one of: instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp - **name** (required) `string`: No description - **description** `string`: No description - **steps** `array`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description ### Responses #### 200: Sequence created **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **status** `string`: No description - **stepsCount** `integer`: No description - **createdAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") --- --- # Pause sequence API Reference Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated. ## POST /v1/sequences/{sequenceId}/pause **Pause sequence** Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence paused **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **status** `string`: No description #### 400: Sequence is not active #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Unenroll contact API Reference Remove a contact from a sequence. No further messages will be sent to this contact. ## DELETE /v1/sequences/{sequenceId}/enroll/{contactId} **Unenroll contact** Remove a contact from a sequence. No further messages will be sent to this contact. ### Parameters - **sequenceId** (required) in path: No description - **contactId** (required) in path: No description ### Responses #### 200: Contact unenrolled #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Update sequence API Reference Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused. ## GET /v1/sequences/{sequenceId} **Get sequence with steps** Returns a sequence with all its steps and enrollment stats. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence details with steps **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **platform** `string`: No description - **accountId** `string`: No description - **status** `string`: No description - one of: draft, active, paused - **steps** `array[object]`: - **order** `integer`: No description - **delayMinutes** `integer`: No description - **message** `object`: - **text** `string`: No description - **template** `object`: - **name** `string`: No description - **language** `string`: No description - **variableMapping** `object`: No description - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **totalEnrolled** `integer`: No description - **totalCompleted** `integer`: No description - **totalExited** `integer`: No description - **createdAt** `string` (date-time): No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## PATCH /v1/sequences/{sequenceId} **Update sequence** Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused. ### Parameters - **sequenceId** (required) in path: No description ### Request Body - **name** `string`: No description - **description** `string`: No description - **steps** `array`: Replace the full step list. Only allowed while the sequence is draft or paused. - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description ### Responses #### 200: Sequence updated **Response Body:** - **success** `boolean`: No description - **sequence** `object`: - **id** `string`: No description - **name** `string`: No description - **description** `string`: No description - **status** `string`: No description - **steps** `array[object]`: Type: `object` - **exitOnReply** `boolean`: No description - **exitOnUnsubscribe** `boolean`: No description - **updatedAt** `string` (date-time): No description #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- ## DELETE /v1/sequences/{sequenceId} **Delete sequence** Permanently delete a sequence. Active enrollments are stopped. ### Parameters - **sequenceId** (required) in path: No description ### Responses #### 200: Sequence deleted #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 404: Resource not found **Response Body:** - **error** `string`: No description (example: "Not found") --- --- # Share a tracking tag with an ad account API Reference Shares the pixel with another ad account so campaigns/audiences in that account can use it. Requires that you administer both the pixel's owning Business Manager and the target ad account; a pixel on a personal (non-BM) ad account can't be shared (Meta will reject the call). Meta only (platform `metaads`); other platforms return 405. ## GET /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts **List ad accounts a tracking tag is shared with** Meta only (platform `metaads`); other platforms return 405. ### Parameters - **accountId** (required) in path: No description - **tagId** (required) in path: Pixel id. ### Responses #### 200: Shared ad accounts listed **Response Body:** - **platform** `string`: No description - one of: metaads - **sharedAccounts** `array[SharedAdAccount]`: #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required). #### 404: Account or tracking tag not found. #### 405: Platform does not support shared accounts. --- ## POST /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts **Share a tracking tag with an ad account** Shares the pixel with another ad account so campaigns/audiences in that account can use it. Requires that you administer both the pixel's owning Business Manager and the target ad account; a pixel on a personal (non-BM) ad account can't be shared (Meta will reject the call). Meta only (platform `metaads`); other platforms return 405. ### Parameters - **accountId** (required) in path: No description - **tagId** (required) in path: Pixel id. ### Request Body - **adAccountId** (required) `string`: Ad account to share with, e.g. `act_123456789`. ### Responses #### 201: Tracking tag shared with the ad account **Response Body:** - **platform** `string`: No description - one of: metaads - **ok** `boolean`: No description #### 400: Invalid body / `adAccountId`, or Meta rejected the share (e.g. personal ad account). #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required). #### 404: Account or tracking tag not found. #### 405: Platform does not support shared accounts. --- ## DELETE /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts **Stop sharing a tracking tag with an ad account** `adAccountId` may be passed as a query parameter (recommended) or as a JSON body field for clients that can send DELETE bodies. Meta only (platform `metaads`); other platforms return 405. ### Parameters - **accountId** (required) in path: No description - **tagId** (required) in path: Pixel id. - **adAccountId** (optional) in query: Ad account to unshare, e.g. `act_123456789`. May also be sent in the JSON body. ### Responses #### 204: Ad account unshared (no content). #### 400: `adAccountId` missing (neither query nor body), or Meta rejected the unshare. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required). #### 404: Account or tracking tag not found. #### 405: Platform does not support shared accounts. --- # Related Schema Definitions ## SharedAdAccount An ad account a tracking tag is shared with (Meta `shared_accounts` edge). ### Properties - **id** (required) `string`: Ad account id, in `act_` form. - **name** `string`: No description - **businessId** `string`: Business Manager id that owns the ad account --- # Create a tracking tag (Meta Pixel) API Reference Creates a Meta Pixel on the given ad account (`POST /act_{id}/adspixels` — `name` is the only input). Returns the created tag including its install `code`. The pixel is owned by the Business Manager that owns the ad account; a pixel created on a personal (non-BM) ad account ends up with `ownerBusinessId: null` and can't be shared with other ad accounts. Creating a pixel does NOT install it — install the returned `code` snippet on the site, or send events server-side via `POST /v1/ads/conversions`. The check `installed` is derived from `lastFiredTime`. NOT idempotent: each call creates a new pixel. Do not retry blindly on timeout. Meta only (platform `metaads`); other platforms return 405. ## GET /v1/accounts/{accountId}/tracking-tags **List tracking tags (Meta Pixels)** Returns the tracking tags (Meta Pixels) the connected ads account can see. Pass `?adAccountId=act_...` to scope the list to a single ad account; omit it to list every pixel reachable by the token (the name is then suffixed with the ad account it was discovered on, for disambiguation). The list view omits `code` — call `getTrackingTag` for the install snippet and full detail. Meta only today (platform `metaads`); other platforms return 405. The `accountId` must be the Meta *ads* SocialAccount created by the Ads add-on connect flow, not a Facebook/Instagram posting account. Get your `act_...` ids from `GET /v1/ads/accounts`. ### Parameters - **accountId** (required) in path: Meta ads SocialAccount id (platform `metaads`). - **adAccountId** (optional) in query: Optional. Scope to one ad account, e.g. `act_123456789`. ### Responses #### 200: Tracking tags listed **Response Body:** - **platform** `string`: No description - one of: metaads - **tags** `array[TrackingTag]`: #### 400: Account platform not supported, or invalid `adAccountId`. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required). #### 404: Account not found or not accessible. #### 405: Platform does not support listing tracking tags. --- ## POST /v1/accounts/{accountId}/tracking-tags **Create a tracking tag (Meta Pixel)** Creates a Meta Pixel on the given ad account (`POST /act_{id}/adspixels` — `name` is the only input). Returns the created tag including its install `code`. The pixel is owned by the Business Manager that owns the ad account; a pixel created on a personal (non-BM) ad account ends up with `ownerBusinessId: null` and can't be shared with other ad accounts. Creating a pixel does NOT install it — install the returned `code` snippet on the site, or send events server-side via `POST /v1/ads/conversions`. The check `installed` is derived from `lastFiredTime`. NOT idempotent: each call creates a new pixel. Do not retry blindly on timeout. Meta only (platform `metaads`); other platforms return 405. ### Parameters - **accountId** (required) in path: Meta ads SocialAccount id (platform `metaads`). ### Request Body - **adAccountId** (required) `string`: Meta ad account id, e.g. `act_123456789`. - **name** (required) `string`: No description ### Responses #### 201: Tracking tag created **Response Body:** - **platform** `string`: No description - one of: metaads - **tag**: `TrackingTag` - See schema definition #### 400: Invalid body, invalid `adAccountId`, over the per-business pixel cap, or ad account not in a Business Manager. #### 401: Unauthorized **Response Body:** - **error** `string`: No description (example: "Unauthorized") #### 403: Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required). #### 404: Account not found or not accessible. #### 405: Platform does not support creating tracking tags. --- # Related Schema Definitions ## TrackingTag A platform measurement tag — the thing you create, install on a website, send events to, and target ads against. On Meta this is a Pixel (`kind: pixel`). The shape is platform-neutral so other platforms (Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without changing the contract; platform-specific fields are simply absent where a platform has no equivalent. Returned by `listTrackingTags`, `createTrackingTag`, `getTrackingTag`, and `updateTrackingTag`. ### Properties - **id** (required) `string`: Platform-native tag id. Meta: numeric pixel id, as a string. - **name** (required) `string`: No description - **platform** (required) `string`: No description - one of: metaads - **kind** (required) `string`: Platform-native flavor of the tag (Meta: `pixel`). - one of: pixel, tag, insight_tag - **status** (required) `string`: `inactive` when the platform reports the tag as broken/unavailable. - one of: active, inactive - **code** `string`: The base-code `