# 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). | | [`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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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`.
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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.
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- ## `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).
--- # 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 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete group Permanently deletes an account group. The accounts themselves are not affected. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update group Updates the name or account list of an existing group. You can rename the group, change its accounts, or both. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete IG ice breakers Removes the ice breaker questions from an Instagram account's Messenger experience. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete FB persistent menu Removes the persistent menu from Facebook Messenger conversations for this account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete TG bot commands Clears all bot commands configured for a Telegram bot account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get IG ice breakers Get the ice breaker configuration for an Instagram account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get FB persistent menu Get the persistent menu configuration for a Facebook Messenger account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get TG bot commands Get the bot commands configuration for a Telegram account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set IG ice breakers Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set FB persistent menu Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set TG bot commands Set bot commands for a Telegram account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Disconnect account Disconnects and removes a connected social account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Check account health Returns detailed health info for a specific account including token status, permissions, and recommendations. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Check accounts health Returns health status of all connected accounts including token validity, permissions, and issues needing attention. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete custom audience Deletes the audience from both Meta and the local database. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get audience details Returns the local audience record and fresh data from Meta (if available). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List custom audiences Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X (Twitter). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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[]`). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Archive a Lead Gen form Meta has no hard delete for forms; this archives the form (status=ARCHIVED). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Boost post as ad Creates a paid ad campaign from an existing published post. Creates the full platform campaign hierarchy (campaign, ad set, ad). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create a conversion destination (LinkedIn) Create a new conversion rule on the platform. LinkedIn-only today; other platforms manage destinations in their own UIs and return 405. For 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Cancel an ad Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get a single Lead Gen form {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List Lead Gen (Instant) forms Lists the Lead Gen forms owned by the connected Facebook Page. Requires the Ads add-on. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create key Creates a new API key with an optional expiry. The full key value is only returned once in the response. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete key Permanently revokes and deletes an API key. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List keys Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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/. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Add recipients to a broadcast Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Cancel broadcast Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create broadcast draft Create a broadcast in draft status. Add recipients and then send or schedule it. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete broadcast Permanently delete a broadcast. Only drafts can be deleted. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get broadcast details Returns a broadcast with its full configuration and delivery stats. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List broadcast recipients Returns recipients for a broadcast with individual delivery status. Filter by status. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List broadcasts Returns broadcasts with delivery stats. Filter by status, platform, or profile. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Schedule broadcast for later Schedule a draft broadcast to be sent at a future date and time. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send broadcast now Immediately start sending a draft broadcast to its recipients. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update broadcast Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete automation Permanently delete an automation and all its trigger logs. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get automation details Returns an automation with its configuration, stats, and recent trigger logs. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List automation logs Paginated list of every comment that triggered this automation, with send status and commenter info. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List comment-to-DM automations List all comment-to-DM automations for a profile. Returns automations with their stats. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete comment Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn. Requires accountId and commentId query parameters. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get post comments Fetch comments for a specific post. Requires accountId query parameter. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Reply to comment Post a reply to a post or specific comment. Requires accountId in request body. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Unhide comment Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Unlike comment Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit. For Bluesky, the likeUri query parameter is required. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Bulk create contacts Import up to 1000 contacts at a time. Skips duplicates. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create contact Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete contact Permanently deletes a contact and all associated channels. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List channels for a contact Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get contact Returns a contact with all associated messaging channels. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List contacts List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update contact Update one or more fields on a contact. Only provided fields are changed. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List Facebook pages Returns all Facebook pages the connected account has access to, including the currently selected page. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List LinkedIn orgs Returns LinkedIn organizations (company pages) the connected account has admin access to. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List Pinterest boards Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List subreddit flairs Returns available post flairs for a subreddit. Some subreddits require a flair when posting. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Complete OAuth callback Exchange the OAuth authorization code for tokens and connect the account to the specified profile. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List LinkedIn orgs Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List Pinterest boards For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List Snapchat profiles For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update Facebook page Switch which Facebook Page is active for a connected account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update GBP location Switch which GBP location is active for a connected account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Switch LinkedIn account type Switch a LinkedIn account between personal profile and organization (company page) posting. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set default Pinterest board Sets the default board used when publishing pins for this account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set default subreddit Sets the default subreddit used when publishing posts for this Reddit account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Clear custom field value Remove a custom field value from a contact. The field definition is not affected. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create custom field Create a new custom field definition. Supported types are text, number, date, boolean, and select. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete custom field Delete a custom field definition and remove its values from all contacts. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List custom field definitions Returns all custom field definitions. Optionally filter by profile. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Set custom field value Set or overwrite a custom field value on a contact. The value type must match the field definition. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update custom field Update a custom field definition. The field type cannot be changed after creation. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get a Discord scheduled event {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get Discord account settings Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete photo Deletes a photo or media item from a GBP location. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete action link Deletes a place action link (e.g. booking or ordering URL) from a GBP location. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete a review reply Removes the business owner reply from a Google Business review. The review itself remains. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get attributes Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get food menus Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get location details Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get reviews Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List media Lists media items (photos) for a Google Business Profile location. Returns photo URLs, descriptions, categories, and metadata. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update attributes Updates location attributes (amenities, services, etc.). The attributeMask specifies which attributes to update (comma-separated). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update food menus Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update action link Updates a place action link (change URL or action type). Only the fields included in the request body will be updated. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Edit message Edit the text and/or reply markup of a previously sent Telegram message. Only supported for Telegram. Returns 400 for other platforms. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get conversation Retrieve details and metadata for a specific conversation. Requires accountId query parameter. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update conversation status Archive or activate a conversation. Requires accountId in request body. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Bulk upload from CSV Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get post Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List posts Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Retry failed post Immediately retries publishing a failed post. Returns the updated post with its new status. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create profile Creates a new profile with a name, optional description, and color. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete profile Permanently deletes a profile by ID. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get profile Returns a single profile by ID, including its name, color, and default status. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List profiles Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update profile Updates a profile's name, description, color, or default status. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create schedule Create an additional queue for a profile. The first queue created becomes the default. Subsequent queues are non-default unless explicitly set. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Preview upcoming slots Returns the next N upcoming queue slot times for a profile as ISO datetime strings. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get subreddit feed Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Search posts Search Reddit posts using a connected account. Optionally scope to a specific subreddit. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete review reply Delete a reply to a review (Google Business only). Requires accountId in request body. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Reply to review Post a reply to a review. Requires accountId in request body. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Activate sequence Start a draft or paused sequence. The sequence must have at least one step. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create sequence Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete sequence Permanently delete a sequence. Active enrollments are stopped. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Enroll contacts in a sequence Enroll one or more contacts into a sequence. Contacts already enrolled are skipped. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get sequence with steps Returns a sequence with all its steps and enrollment stats. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List enrollments for a sequence Returns enrolled contacts with their progress, status, and next scheduled step. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List sequences Returns sequences with enrollment stats. Filter by status, platform, or profile. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Pause sequence Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Unenroll contact Remove a contact from a sequence. No further messages will be sent to this contact. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update sequence Update a sequence's name, steps, or exit conditions. Steps can only be modified while the sequence is draft or paused. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Aggregated event stats for a tracking tag (Meta Pixel) Returns aggregated event counts for the pixel (`GET /{pixel_id}/stats`). Rows are passed through from Meta as-is — their shape depends on the `aggregation` requested. Meta only (platform `metaads`); other platforms return 405. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Fetch a single tracking tag (Meta Pixel) Returns the full tag record including the base-code `code` snippet, `lastFiredTime`, `ownerBusinessId`, `isUnavailable`, etc. Meta only (platform `metaads`); other platforms return 405. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List ad accounts a tracking tag is shared with Meta only (platform `metaads`); other platforms return 405. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update a tracking tag (Meta Pixel) Partial-update a pixel. Whitelisted fields: `name` (rename), `enableAutomaticMatching`, `automaticMatchingFields`, `firstPartyCookieStatus`, `dataUseSetting`. At least one is required. Returns the re-fetched canonical tag. Meta only (platform `metaads`); other platforms return 405. There is no DELETE — Meta has no API to delete a pixel. To stop using one, unshare it from your ad accounts (`DELETE .../tracking-tags/{tagId}/shared-accounts`) or disable it in Events Manager. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Bookmark a tweet Bookmark a tweet by ID. Requires the bookmark.write OAuth scope. Rate limit: 50 requests per 15-min window. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Follow a user Follow a user on X/Twitter. Requires the follows.write OAuth scope. For protected accounts, a follow request is sent instead (pending_follow will be true). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Remove bookmark Remove a bookmark from a tweet. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Retweet a post Retweet (repost) a tweet by ID. Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Undo retweet Undo a retweet (un-repost a tweet). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Unfollow a user Unfollow a user on X/Twitter. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get plan and usage stats Returns the current plan name, billing period, plan limits, and usage counts. The response shape depends on the account's `billingSystem`: * Stripe users: per-period `usage.uploads` / `usage.profiles` counters. * Metronome (usage-based) users: `usage.connectedAccounts`, `usage.xApiCallsByOperation` (per-operation X API call counts — resolve keys via `GET /v1/billing/x-pricing`), plus a `spend` block with `currentPeriodCents`, `xSpendCents`, and `xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier aggregate is still emitted for back-compat but excludes the $0.200 URL tier and any future tiers — new clients should consume `xApiCallsByOperation` only. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get X/Twitter API pricing table Returns Zernio's canonical X/Twitter API pricing table. Each X action has its own Metronome product and its own rate, and Zernio passes X API costs through at exact rates with zero markup. The response is identical for every authenticated user (pricing is universal), so it is safe to cache on the client for the duration of a billing period. To compute your own per-operation spend, pair this endpoint with `GET /v1/usage-stats` — that endpoint returns `usage.xApiCallsByOperation` keyed by the same `operation` field you get here. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get user Returns a single user's details by ID, including name, email, and role. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List users Returns all users in the workspace including roles and profile access. Also returns the currentUserId of the caller. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Validate media URL Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons. Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection. Platform limits are sourced from each platform's actual upload constraints. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Validate character count Check weighted character count per platform and whether the text is within each platform's limit. Twitter/X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length. Returns counts and limits for all 15 supported platform variants. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Validate post content Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations. Accepts the same body as POST /v1/posts. Does NOT validate accounts, process media, or track usage. This is content-only validation. Returns errors for failures and warnings for near-limit content (>90% of character limit). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Check subreddit existence Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed). When accountId is provided, uses authenticated Reddit OAuth API with automatic token refresh (recommended). Falls back to Reddit's public JSON API, which may be unreliable from server IPs. Returns exists: false for private, banned, or nonexistent subreddits. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create webhook Create a new webhook configuration. Maximum 10 webhooks per user. `name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation. Webhooks are automatically disabled after 10 consecutive delivery failures. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete webhook Permanently delete a webhook configuration. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List webhook delivery logs Retrieve recorded webhook delivery attempts for the authenticated user, most recent first. Logs are retained for 30 days. Supports filtering by status, event type, webhook ID, and event ID, plus offset-based pagination. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List webhooks Retrieve all configured webhooks for the authenticated user. Supports up to 10 webhooks per user. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send test webhook Send a test webhook to verify your endpoint is configured correctly. The test payload includes event: "webhook.test" to distinguish it from real events. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update webhook Update an existing webhook configuration. All fields except `_id` are optional; only provided fields will be updated. When provided, `name` must be 1-50 characters, `url` must be a valid URL, and `events` must contain at least one event. Whitespace is trimmed from `url` before validation. Webhooks are automatically disabled after 10 consecutive delivery failures. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Add participants Add participants to a WhatsApp group. Maximum 8 participants per request. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Approve join requests Approve pending join requests for a WhatsApp group. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Block users Block one or more WhatsApp users on this number. Blocked users cannot message your number or see that you are online, and your sends to them return an error. Meta constraints, surfaced per-user in `failed` (the request itself still succeeds for the rest of the batch): - Only users who messaged your business within the last 24 hours can be blocked (failures outside the window report "Re-engagement required"). - Up to 1,000 users per request; the blocklist caps at 64,000. - Other WhatsApp Business accounts cannot be blocked. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Provision CTWA conversions dataset Creates (or fetches, if one already exists) the Meta dataset that Click-to-WhatsApp ad events are reported against via the Conversions API, and persists its ID on the account as `metadata.metaCapiDatasetId`. The call is GET-first idempotent — a WABA can only own one CTWA dataset, so a second call after a successful provision is a safe no-op that returns the same ID with `created: false`. Requires the connected WhatsApp account's token to carry the `whatsapp_business_manage_events` permission. If the permission is missing the endpoint returns 422 with a message asking the user to reconnect the account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create group Create a new WhatsApp group chat. Returns the group ID and optionally an invite link. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create invite link Create a new invite link for a WhatsApp group. The previous link is revoked. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create template Create a new message template. Supports two modes: Custom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h). Library template: Provide library_template_name instead of components to use a pre-built template from Meta's template library. Library templates are pre-approved (no review wait). You can optionally customize parameters and buttons via library_template_body_inputs and library_template_button_inputs. Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/ {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete group Delete a WhatsApp group and remove all participants. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete template Permanently delete a message template by name. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List blocked users List the WhatsApp users blocked on this number. Cursor-paginated; pass `nextCursor` back as `after` to fetch the next page. The blocklist holds up to 64,000 users. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get business profile Retrieve the WhatsApp Business profile for the account (about, address, description, email, websites, etc.). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get CTWA conversions dataset Returns the Meta Click-to-WhatsApp conversions dataset currently linked to the WhatsApp account, if one has been provisioned. Reads only from the stored `metadata.metaCapiDatasetId` — never hits Meta, never creates a dataset. Use this to detect whether `POST /v1/whatsapp/conversions` is configured for an account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get display name status Fetch the current display name and its Meta review status for a WhatsApp Business account. Display name changes require Meta approval and can take 1-3 business days. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get group info Retrieve metadata about a WhatsApp group including subject, description, participants, and settings. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Look up a library template Look up a single pre-approved Template Library template by its exact name, to introspect its structure before importing it. Most importantly it returns the template's `buttons`: a library template with `URL` / `PHONE_NUMBER` buttons must be created with a matching `library_template_button_inputs` array (see Create Template), or Meta rejects it. Use this to discover which inputs to collect. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get template Retrieve a single message template by name. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List templates List all message templates for the WhatsApp Business Account (WABA) associated with the given account. Templates are fetched directly from the WhatsApp Cloud API. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List active groups List active WhatsApp group chats for a business phone number. These are actual WhatsApp group conversations on the platform. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List join requests List pending join requests for a WhatsApp group (only for groups with approval_required mode). Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Reject join requests Reject pending join requests for a WhatsApp group. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Remove participants Remove participants from a WhatsApp group. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # 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. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Unblock users Unblock one or more previously blocked WhatsApp users on this number. Up to 1,000 users per request; per-user failures are reported in `failed` without failing the rest of the batch. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update business profile Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated. Constraints: about max 139 chars, description max 512 chars, max 2 websites. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Request display name change Submit a display name change request for the WhatsApp Business account. The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business). Changes require Meta review and approval, which typically takes 1-3 business days. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update group settings Update the subject, description, or join approval mode of a WhatsApp group. Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update template Update a message template's components. Only certain fields can be updated depending on the template's current approval state. Approved templates can only have components updated. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Upload profile picture Upload a new profile picture for the WhatsApp Business Profile. Uses Meta's resumable upload API under the hood: creates an upload session, uploads the image bytes, then updates the business profile with the resulting handle. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Disable calling on a number Disable calling. Sends calling.status=DISABLED to Meta (best-effort) and flips the local `callingEnabled` flag off. forwardTo and SIP creds are preserved so a re-enable does not lose the destination. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Enable calling on a number Enable WhatsApp Business Calling on a connected number. Configures Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and stores the Meta-issued SIP password (encrypted), and snapshots the customer's forward-to destination. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Estimate per-minute cost for a destination Returns a zero-markup estimated cost for an outbound call to the given destination, broken down by Meta + Telnyx + recording line items. Costs are pass-through, no margin applied. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Check call permission for a consumer Returns the permission state and the list of available actions for a given consumer wa_id (e.g. `start_call`, `send_call_permission_request`). Use this before placing a call to decide whether to prompt for consent first. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get a single call {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get calling config for an account Returns the local calling configuration snapshot for the connected WhatsApp account: whether calling is enabled, the forward-to destination URI, recording opt-in state, the WhatsAppPhoneNumber doc id (use as `{id}` on the calling-config write endpoints) and whether SIP digest credentials are stored (the encrypted password itself is never returned). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Initiate outbound call Initiates an outbound Business-Initiated Call. The Telnyx-side SIP leg is originated server-side (Option B: SIP-first). Telnyx INVITEs Meta directly over TLS:5061 with the SIP digest credentials we captured at calling-enablement time). No client-side SDP is required; pass only `accountId` and `to`. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List call history for an account Compact history listing for a single connected account. Results are scoped to the resolved SocialAccount; profile-scoped team members cannot read calls on sibling accounts. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update calling config Update fields on an already-enabled number. Only fields present in the body are written; `undefined` leaves the stored value alone, explicit `null` clears a nullable field. No Meta side effect, this only changes local routing state consumed by the Telnyx webhook handler. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create flow Create a new WhatsApp Flow in DRAFT status. Optionally clone an existing flow. After creating, upload a Flow JSON definition, then publish to make it sendable. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete flow Delete a DRAFT flow. This is irreversible. Only flows in DRAFT status can be deleted. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Deprecate flow Deprecate a PUBLISHED flow. This is irreversible. Deprecated flows cannot be sent or opened, but existing active sessions may continue until they complete. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get flow JSON asset Get the flow JSON asset metadata, including a temporary download URL for the Flow JSON file. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get flow preview URL Get Meta's public web-preview URL for a flow (drafts included), embeddable as an interactive iframe. The link is reused across calls (valid ~30 days); pass invalidate=true to mint a fresh one (the previous link stops working). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get flow Get details for a specific flow, including status, categories, validation errors, and preview URL. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List flow responses List the responses customers submitted when completing a flow (parsed from the nfm_reply messages received via webhook), newest first. Scope to a single flow with `flowId` — this matches responses whose flow_token carries the `:` prefix that Zernio stamps on auto-generated tokens at send time. Responses sent with a custom integrator-supplied flow_token are not attributed to a flow. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List flow versions List the flow's version history (the clone lineage Zernio tracks, since Meta has no native versioning), newest version first. Each entry is enriched with the version's live name and status from Meta. A flow with no lineage returns just itself as version 1. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List flows List all WhatsApp Flows for the Business Account (WABA) associated with the given account. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Publish flow Publish a DRAFT flow. This is irreversible. Once published, the flow and its JSON become immutable and the flow can be sent to users. To update a published flow, create a new flow (optionally cloning this one via cloneFlowId). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send flow message Send a published flow as an interactive message with a CTA button. When the recipient taps the button, the flow opens natively in WhatsApp. Flow responses are received via webhooks. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update flow Update metadata (name, categories) of a DRAFT flow. Published flows are immutable. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Upload flow JSON Upload or update the Flow JSON for a DRAFT flow. The Flow JSON defines all screens, components (text inputs, dropdowns, date pickers, etc.), and navigation. Meta validates the JSON on upload and returns any validation errors. See: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Check a country's availability + address constraint Pre-purchase check, so you can warn BEFORE a customer invests in KYC (regulated review is async, 1-3 days). Tells you whether we have deliverable inventory, and what address the customer needs: - `addressConstraint: geo` → the registered address MUST be in one of the returned `areas` (the only place we have stock). A different-area address passes pre-approval but the number can never be assigned. - `addressConstraint: country` → any in-country address works. - `addressConstraint: none` → field-only / instant country, no address. Call this before starting the KYC form for regulated countries. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get number status Live snapshot of a connected number straight from Meta: the phone-number node (display number, display name + approval, quality rating, messaging-limit tier, throughput, official-business badge, connection status, health_status) and its owning WhatsApp Business Account (name, business verification, timezone, health_status). Fetched live because Meta updates quality/tier/name/health over time; the call also refreshes the cached values shown on the connection card. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get regulated-number KYC form spec For a Tier 3/4 country, the fields the end customer must provide (Telnyx regulatory requirements) before a number can be ordered: text, date, address, or file (document) per requirement. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get the declined requirements to fix For a number in `regulatory_declined`, returns ONLY the requirements the reviewer flagged declined, as a form spec (same shape as the KYC form GET). The customer fixes just those — Telnyx supports correcting a declined requirement group and re-submitting it (no new number/group). Falls back to the full spec if the provider exposes no per-requirement flags. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get phone number Retrieve the current status of a purchased phone number. Poll this to track Meta pre-verification (US sync path) and, for regulated (Tier 3/4) numbers, the async lifecycle: pending_regulatory → active (or regulatory_declined). When a regulated number has an Onfido ID step, `onfidoVerificationUrl` appears here once the order is placed — forward it to the end user. (Or subscribe to the whatsapp.number.* webhooks instead of polling.) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List phone numbers List all WhatsApp phone numbers purchased by the authenticated user. By default, released numbers are excluded. Connected (bring-your-own) numbers are returned in the separate `connected` array — they are not billed and have no provisioning lifecycle. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List offerable number countries The WhatsApp number countries available to purchase, each with its flat monthly price (cents), regulatory tier, whether it needs end-user KYC (Tier 3/4), and whether outbound calling is available (not BIC-blocked). Drives the country picker. Tier-4 countries appear only when enabled. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Purchase phone number Initiate purchasing a WhatsApp phone number. Payment-first flow: the user does not pick a specific number. The system either creates a Stripe Checkout Session (first number) or increments the existing subscription quantity and provisions inline (subsequent numbers). Requires a paid plan. The maximum number of phone numbers is determined by the user's plan. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Release phone number Release a purchased phone number. This will: 1. Disconnect any linked WhatsApp social account 2. Decrement the Stripe subscription quantity (or cancel if last number) 3. Release the number from Telnyx 4. Mark the number as released {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Fix a declined number and re-submit Submit corrected values/documents for the declined requirement(s). We PATCH them onto the SAME requirement group and re-submit it for approval; the number goes `regulatory_declined` → `pending_regulatory`. No new number and no new billing. Body shape matches the KYC submit (values / documents / address) — send only the corrected fields. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Search available numbers to purchase Search the provider's inventory for numbers available to purchase in a country (default US). Optional filters narrow the results. The country must be offerable (see GET /v1/whatsapp/phone-numbers/countries). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Submit regulated-number KYC Submit the end customer's KYC (textual values, uploaded documents, address) for a Tier 3/4 country. Documents are streamed straight to the number provider and are not stored by Zernio. Builds + submits a regulatory requirement group and claims a pending_regulatory slot; the number is ordered + activated once the provider approves (asynchronous). A customer may hold several same-country numbers in review at once; a double-submit of the SAME attempt is deduped via `submissionId`. For an ID-card document requirement, carriers commonly require BOTH sides: combine the front and back into a single file before uploading (the dashboard does this automatically). A one-sided ID is a common decline reason; fix it via POST /v1/whatsapp/phone-numbers/{id}/remediate. Before submitting, call GET /v1/whatsapp/phone-numbers/availability to check the country has deliverable inventory and, for geographic-match countries, which area the address must be in — otherwise the submission can pass review yet never be assignable a number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Upload a single regulated-number KYC document Upload ONE document and get back its provider document id, to reference from POST /v1/whatsapp/phone-numbers/kyc via `documents[].documentId`. Send the RAW file bytes as the request body (not base64); put the filename in the `X-Filename` header. Uploading documents one-per-request keeps each request under the ~4.5MB body limit. The document streams straight to the number provider and is not stored by Zernio. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Pre-validate a regulated-number KYC address (Tier 4) Optional early check for the address step of a Tier 4 (end-user identity) registration: validates a postal address for deliverability BEFORE the full KYC submit, so it can be corrected before any documents are uploaded. The full submit (POST /v1/whatsapp/phone-numbers/kyc) re-validates the address, so this call is purely a fast feedback path and skipping it is safe. Only the postal address is sent (no documents, no gov-ID fields). A region (`administrative_area`) is required by the validator; when it is omitted the pre-check is skipped and `{ ok: true, skipped: true }` is returned (the final submit still validates). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Start a sandbox activation for a phone Creates (or refreshes) a pending sandbox session for the given phone and immediately fires the verified sandbox template from the shared sandbox number to that phone. The session activates when the phone owner replies to that WhatsApp message — the reply itself is proof of ownership. One phone per user: if the caller already has a non-expired session for a DIFFERENT phone, the request is rejected with `invalid_field_value` (the message names the existing phone so it can be revoked first). Re-creating a session for the SAME phone is idempotent and refreshes the verification template. If Meta rejects the template send (not a WhatsApp number, paused WABA, token issue), the pending row is rolled back and the Meta error message is returned in `error` so the caller knows why. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Revoke a sandbox session Hard-deletes the session. The user loses the ability to send to that phone via the sandbox until they re-activate it. Existing conversations and messages already exchanged with that phone are untouched — revocation only blocks FUTURE sends. Sessions belonging to other users cannot be revoked; the response is the same 400 as "session not found" so existence isn't leaked. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List your sandbox sessions Returns all of the authenticated user's non-expired sandbox sessions (pending + active) plus the sandbox phone number. In practice there is at most one session per user since the sandbox is one-phone-per-user; the array shape is preserved for forward compatibility. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Activate workflow Validate the graph is runnable and set the workflow live. Once active, matching inbound messages start executions. Idempotent. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create workflow Create a branching conversation workflow (draft) from a node/edge graph. Created in `draft` status; activate it to start matching inbound messages. The graph is validated structurally; completeness (a trigger node + reachable entry) is required at activation. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Delete workflow Permanently delete a workflow and all of its executions. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Duplicate a workflow Create an independent copy of a workflow's graph, name, description, and account binding. The copy is created in `draft` status with fresh execution counters and a new id — execution history is NOT copied. Useful for branching off a known-good workflow before making experimental edits. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get a specific workflow version Returns the full snapshot for a single historical version, including the graph. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get workflow with graph Returns a workflow including its full node/edge graph and run stats. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Get an execution's timeline Returns the per-step run-log for a single workflow execution: trigger fired, each node visited, edge handles taken, errors, and durations. Backed by Tinybird (90-day retention). Used by the Runs UI drawer to render the timeline. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List workflow runs Returns recent executions (runs) with their status, current node, and accumulated variables. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List a workflow's version history Returns the snapshot history. A new version is recorded automatically before every PATCH to `nodes` / `edges` / `entryNodeId`, and explicitly when a previous version is restored. Lightweight list — call `getWorkflowVersion` for the full snapshot graph. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List workflows Returns workflows with run stats. Filter by status or profile. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Pause workflow Stop matching new inbound messages. In-flight executions continue to completion. Idempotent. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Restore a previous workflow version Replace the current graph with the named version's snapshot. Before the swap, the current graph is itself snapshotted as a new version, so a restore is reversible. The workflow must be in `draft` or `paused` status (same gate as a normal graph edit). The returned workflow carries `restoredFromVersion` so the UI can surface which version was rolled back to. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Manually start a workflow run Kick off a run without waiting for an inbound message (useful for testing). Target an existing conversation by `conversationId`, or — WhatsApp only — a phone number via `to` (a conversation is found or created). `text` seeds the run's `lastMessage` variable. The graph must be runnable. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Update workflow Update name, description, the graph, or reassign to a different account. The graph can only be modified while the workflow is draft or paused. Account swaps re-validate the graph against the new platform (so e.g. moving from WhatsApp to Facebook surfaces a `start_call` node as an error instead of silently saving an unrunnable graph). {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Connecting Accounts How to connect social media accounts using OAuth flows, headless mode, and non-OAuth platforms import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Before you can post to a platform, you need to connect a social media account to a profile. Zernio supports 14 platforms, each with its own connection method. ## OAuth Flow (Most Platforms) Most platforms use OAuth. The basic flow is: 1. Call `GET /v1/connect/{platform}` with your `profileId` 2. The API returns an `authUrl` 3. Redirect the user to that URL to authorize 4. After authorization, the user is redirected back to your `redirect_url` 5. The account is connected ```typescript const { authUrl } = await zernio.connect.getConnectUrl({ platform: 'twitter', profileId: 'prof_abc123', redirectUrl: 'https://myapp.com/callback' }); // Redirect user to authUrl ``` ```python result = client.connect.get_connect_url( platform="twitter", profile_id="prof_abc123", redirect_url="https://myapp.com/callback" ) # Redirect user to result.auth_url ``` ```bash curl "https://zernio.com/api/v1/connect/twitter?profileId=prof_abc123&redirect_url=https://myapp.com/callback" \ -H "Authorization: Bearer YOUR_API_KEY" ``` See the [Start OAuth endpoint](/connect/get-connect-url) for full parameter details. ## Platforms Requiring Secondary Selection Some platforms require an extra step after OAuth - the user needs to select which page, organization, or board to connect: | Platform | What to Select | Endpoints | |----------|---------------|-----------| | Facebook | Page | [List Pages](/connect/list-facebook-pages) → [Select Page](/connect/select-facebook-page) | | LinkedIn | Organization or Personal | [List Orgs](/connect/list-linkedin-organizations) → [Select Org](/connect/select-linkedin-organization) | | Pinterest | Board | [List Boards](/connect/list-pinterest-boards-for-selection) → [Select Board](/connect/select-pinterest-board) | | Google Business | Location | [List Locations](/connect/list-google-business-locations) → [Select Location](/connect/select-google-business-location) | | Snapchat | Public Profile | [List Profiles](/connect/list-snapchat-profiles) → [Select Profile](/connect/select-snapchat-profile) | ### Standard vs Headless Mode **Standard mode** (default): Zernio hosts the selection UI. The user picks their page/org in Zernio's hosted interface, then gets redirected to your `redirect_url`. **Headless mode**: You build your own branded selection UI. Pass `headless=true` when starting the OAuth flow. After OAuth completes, the user is redirected to your `redirect_url` with `tempToken`, `userProfile` (URL-encoded JSON), `step=select_page`, and `connect_token` query params. Your backend then forwards those into the list and select endpoints to finalize the connection. The Node SDK (`@zernio/node`) does not yet expose `connect.listFacebookPages` / `connect.selectFacebookPage`. Use the Python SDK or hit the endpoints directly via fetch. ```python # 1. Start the connect flow, returns the OAuth URL. result = client.connect.get_connect_url( platform="facebook", profile_id="prof_abc123", headless=True, redirect_url="https://your-app.com/cb", ) # Redirect the end-user's browser to result["authUrl"]. # 2. Meta redirects the end-user to your redirect_url with these query # params: profileId, tempToken, userProfile (URL-encoded JSON), # platform=facebook, step=select_page, connect_token. # 3. Your backend lists the user's pages. pages = client.connect.list_facebook_pages( profile_id="prof_abc123", temp_token="", ) # 4. Your backend posts the chosen pageId. user_profile must be the # DECODED dict, json.loads(urllib.parse.unquote(...)) the value # you got in step 2. result = client.connect.select_facebook_page( profile_id="prof_abc123", page_id=pages["pages"][0]["id"], temp_token="", user_profile=json.loads(urllib.parse.unquote("")), redirect_url="https://your-app.com/final-success", ) # Redirect the browser to result["redirect_url"]; result["account"]["accountId"] # is the SocialAccount ID for the connected page. ``` ```bash # 1. Start the connect flow, returns the OAuth URL. curl "https://zernio.com/api/v1/connect/facebook?profileId=prof_abc123&headless=true&redirect_url=https://your-app.com/cb" \ -H "Authorization: Bearer YOUR_API_KEY" # → 200 { "authUrl": "https://www.facebook.com/...", "state": "..." } # Redirect the end-user's browser to authUrl. # 2. Meta redirects the end-user to your redirect_url with profileId, # tempToken, userProfile (URL-encoded JSON), platform=facebook, # step=select_page, and connect_token query params. # 3. Your backend lists the user's pages. curl "https://zernio.com/api/v1/connect/facebook/select-page?profileId=prof_abc123&tempToken=TEMP_TOKEN" \ -H "Authorization: Bearer YOUR_API_KEY" # → 200 { "pages": [{ "id": "...", "name": "...", ... }] } # 4. Your backend posts the chosen pageId. userProfile must be the # decoded JSON object, not the URL-encoded string. curl -X POST "https://zernio.com/api/v1/connect/facebook/select-page" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "prof_abc123", "pageId": "123456789", "tempToken": "TEMP_TOKEN", "userProfile": { "id": "1234567890", "username": "...", "displayName": "..." }, "redirect_url": "https://your-app.com/final-success" }' # → 200 { # "redirect_url": "https://your-app.com/final-success?connected=facebook&profileId=...&username=...", # "account": { "accountId": "", "platform": "facebook", ... } # } # Redirect the browser to redirect_url. ``` ### Connecting Meta Ads only (skip the Page picker) If your end-user only needs ads (audience uploads, [Conversions API](/platforms/meta-ads/capi#conversions-api), analytics, list ads/campaigns), you can auto-pick the first Page in your backend without ever rendering a picker. The end-user transitions from Meta's OAuth screen directly to your "Connected" screen. The flow is identical to the headless flow above, but uses `/v1/connect/facebook/ads` to start and your backend skips any UI step. Roughly 70% of the metaads surface (everything that doesn't emit `object_story_spec.page_id`) works regardless of which Page is bound, so picking the first one is fine for ads-only callers. The remaining 30%, boost Page posts, [Click-to-WhatsApp ads](/platforms/meta-ads/ctwa#click-to-whatsapp-ads), Lead Gen Forms, IS Page-specific. If your end-user later needs those for a specific Page, surface a picker at that point and POST a new `pageId` to `/v1/connect/facebook/select-page`. Our handler updates the existing Facebook account in place, no re-OAuth. #### Scoping sync to specific ad accounts By default, sync covers every `act_*` ad account the connected Meta token can see. That's fine for solo accounts but causes leakage for users in agencies or multi-Business-Manager setups (the token sees every account in every BM the user has a role on). To restrict sync to a specific allowlist, pass `adAccountId` (single) or `adAccountIds` (multiple) on `GET /v1/connect/facebook/ads`: ``` ?adAccountId=act_1330190928038136 ?adAccountIds=act_1330190928038136,act_3686966528111132 ``` Each ID is validated against the connected token's `/me/adaccounts` and persisted server-side. The `account.ads.initial_sync_completed` webhook then carries `account.platformAdAccountId` (when scope is exactly one) and `account.platformAdAccountIds` (always) so you can confirm what was synced. Omit both params to keep the legacy "sync everything visible" behavior. Latest call wins; a subsequent connect with new IDs replaces the prior allowlist. The Node SDK (`@zernio/node`) does not yet expose `connect.listFacebookPages` / `connect.selectFacebookPage`. Use the Python SDK or hit the endpoints directly via fetch for the headless ads-connect flow. ```python # 1. Start the ads connect flow, returns the OAuth URL. result = client.connect.connect_ads( platform="facebook", profile_id="prof_abc123", headless=True, redirect_url="https://your-app.com/cb", ) # Redirect the end-user's browser to result["authUrl"]. # 2. End-user completes Meta OAuth. Browser lands at your redirect_url # with profileId, tempToken, userProfile, step=select_page, etc. # 3. Your backend lists pages and auto-picks the first one, no UI shown. pages = client.connect.list_facebook_pages( profile_id="prof_abc123", temp_token="", ) result = client.connect.select_facebook_page( profile_id="prof_abc123", page_id=pages["pages"][0]["id"], temp_token="", user_profile=json.loads(urllib.parse.unquote("")), redirect_url="https://your-app.com/final-success", ) # Redirect the browser to result["redirect_url"]. The metaads # SocialAccount is created alongside the Facebook account. ``` ```bash # 1. Start the ads connect flow, returns the OAuth URL. curl "https://zernio.com/api/v1/connect/facebook/ads?profileId=prof_abc123&headless=true&redirect_url=https://your-app.com/cb" \ -H "Authorization: Bearer YOUR_API_KEY" # → 200 { "authUrl": "...", "state": "..." } # 2. End-user OAuths. Browser lands at your redirect_url with the # standard headless params (profileId, tempToken, userProfile, etc.). # 3. Your backend lists pages and auto-picks the first one. curl "https://zernio.com/api/v1/connect/facebook/select-page?profileId=prof_abc123&tempToken=TEMP_TOKEN" \ -H "Authorization: Bearer YOUR_API_KEY" curl -X POST "https://zernio.com/api/v1/connect/facebook/select-page" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "prof_abc123", "pageId": "FIRST_PAGE_ID", "tempToken": "TEMP_TOKEN", "userProfile": { "id": "...", "username": "...", "displayName": "..." }, "redirect_url": "https://your-app.com/final-success" }' # Redirect the browser to the response's redirect_url. The metaads # SocialAccount is created alongside the Facebook account. ``` ## Non-OAuth Platforms ### Bluesky Bluesky uses app passwords instead of OAuth: The Node SDK (`@zernio/node`) does not yet expose `connect.connectBlueskyCredentials`. Use the Python SDK or hit the endpoint directly via fetch. ```python account = client.connect.connect_bluesky_credentials( identifier="yourhandle.bsky.social", app_password="your-app-password", state="profile_id=prof_abc123", ) 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": "your-app-password", "state": "profile_id=prof_abc123" }' ``` See [Connect Bluesky](/connect/connect-bluesky-credentials) for details. ### Telegram Telegram uses an access code flow: 1. Call `POST /v1/connect/telegram` to [initiate the connection](/connect/initiate-telegram-connect) and get an access code 2. The user sends this code to the Zernio Telegram bot 3. Poll `GET /v1/connect/telegram` to [check the status](/connect/get-telegram-connect-status) until connected ## Managing Connected Accounts After connecting, you can: - [List all accounts](/accounts/list-accounts) - see all connected accounts - [Update an account](/accounts/update-account) - change settings like default pages or boards - [Check account health](/accounts/get-all-accounts-health) - verify tokens and permissions are valid - [Disconnect an account](/accounts/delete-account) - remove a connection ## Updating Selections After Connection You can change the selected page, organization, or board on an existing connection without re-authenticating: - [Update Facebook Page](/connect/update-facebook-page) - [Update LinkedIn Organization](/connect/update-linkedin-organization) - [Update Pinterest Board](/connect/update-pinterest-boards) - [Update GMB Location](/connect/update-gmb-location) - [Update Reddit Subreddit](/connect/update-reddit-subreddits) --- # Error Handling Error envelope, stable codes, and HTTP status codes returned by the Zernio API import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Error Response Format Every non-2xx response returns a flat JSON envelope modeled on Stripe's error shape: ```json { "error": "budgetAmount is required", "type": "invalid_request_error", "code": "missing_required_field", "param": "budgetAmount", "docUrl": "https://docs.zernio.com/guides/error-handling" } ``` | Field | Stable | Description | |-------|--------|-------------| | `error` | No | Human-readable message. Reworded freely between releases. Never branch client logic on this. | | `type` | Yes | High-level category (`invalid_request_error`, `authentication_error`, `permission_error`, `not_found`, `rate_limit_error`, `platform_error`, `api_error`). | | `code` | Yes | Machine-readable code (e.g. `missing_required_field`, `ads_connection_required`). Use this for programmatic handling. | | `param` | Yes | Field or query parameter at fault, when applicable. Dotted path for nested fields (e.g. `images.square`). | | `docUrl` | Yes | Link to error-specific documentation when available. | | `platform` | Yes | Set on `platform_error`. One of `meta`, `google`, `tiktok`, `linkedin`, `pinterest`, `twitter`. | | `platformError` | Yes | Set on `platform_error`. The raw upstream payload returned by the platform, forwarded unchanged for inspection. | **Backward-compatible.** The top-level `error` string is preserved so existing code that reads `response.error` as a string still works. New fields (`type`, `code`, `param`, ...) are added as top-level siblings, not under a nested `details` object. ### Stability contract - `type` and `code` values are stable once shipped. Build retries, alerting, and i18n against them. - The `error` message can change freely. Treat it as display-only. - New codes may be added at any time. Removing a code is a breaking change. ## Error Types | Type | Default Status | When It Happens | |------|----------------|-----------------| | `invalid_request_error` | 400 / 422 | Missing required fields, wrong types, mutually exclusive fields, invalid JSON, unmet preconditions. | | `authentication_error` | 401 | Missing or invalid API key. | | `permission_error` | 403 | Valid key but feature requires a plan upgrade or add-on (e.g. Ads add-on for AppSumo users; Usage-plan users have all features bundled). | | `not_found` | 404 | Resource doesn't exist or isn't accessible under this API key. | | `rate_limit_error` | 429 | Request rate limit exceeded. See [rate limits](/guides/rate-limits). | | `platform_error` | 502 (or upstream 4xx) | Upstream social platform (Meta, Google, TikTok, LinkedIn, Pinterest, X) rejected the request. See "Platform Errors" below. | | `api_error` | 500 | Unexpected server-side error. Safe to retry with backoff. | ## Common Error Codes | Code | Type | Meaning | |------|------|---------| | `missing_required_field` | invalid_request_error | A required field is missing from the body or query. `param` points at the field. | | `invalid_field_value` | invalid_request_error | A field has the wrong type, format, or enum value. `param` points at the field. | | `mutually_exclusive_fields` | invalid_request_error | Two incompatible fields were both provided (e.g. `creatives[]` + `adSetId`). | | `invalid_json_body` | invalid_request_error | Body could not be parsed as JSON. | | `missing_credentials` | authentication_error | No `Authorization` header. | | `invalid_credentials` | authentication_error | The API key is invalid, revoked, or expired. | | `ads_addon_required` | permission_error | The caller does not have the Ads add-on. | | `feature_not_available` | permission_error | The caller's subscription tier does not include this feature. | | `account_not_found` | not_found | The referenced account is unknown or not accessible. | | `ad_not_found` | not_found | The referenced ad is unknown or not accessible. | | `post_not_found` | not_found | The referenced post is unknown or not accessible. | | `audience_not_found` | not_found | The referenced audience is unknown or not accessible. | | `linked_account_required` | invalid_request_error | The connected account is missing a required linked account (e.g. Instagram Ads requires a linked Facebook account). | | `ads_connection_required` | invalid_request_error | The platform's ads integration isn't connected (e.g. X Ads, TikTok Ads, Google Ads). Call the relevant `/v1/connect/*/ads` flow. | | `instagram_business_account_unresolved` | invalid_request_error | The Instagram Business Account ID couldn't be resolved from the linked Page. The user must connect their Instagram to the Page in Meta Business Settings. | | `missing_square_image` | invalid_request_error | Google Display requires both `images.landscape` and `images.square`; only one was sent. | | `ad_not_commentable` | invalid_request_error | The ad exists but its creative format does not expose a commentable underlying post (e.g. Story ads, Dynamic Product Ads). Returned by `GET /v1/ads/{adId}/comments`. | | `rate_limited` | rate_limit_error | Request rate limit exceeded on this endpoint. | | `platform_api_error` | platform_error | Upstream platform rejected the call. Inspect `platform` + `platformError`. | | `internal_error` | api_error | Unexpected server-side error. | ## Platform Errors When an upstream platform (Meta, Google, TikTok, LinkedIn, Pinterest, X) rejects a request, Zernio surfaces a `platform_error` envelope with the original payload passed through for inspection. ```json { "error": "Google rejected the ad: NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET", "type": "platform_error", "code": "platform_api_error", "platform": "google", "platformError": { "code": 3, "message": "Request contains an invalid argument.", "details": [ { "errors": [ { "errorCode": { "assetError": "NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET" }, "message": "Too few." } ] } ] } } ``` - `platform_error` returns the upstream 4xx status when the platform indicated bad input, or `502 Bad Gateway` when the platform returned 5xx / didn't indicate a status. - Inspect `platformError` for platform-specific error codes you may want to map for retries or end-user messages. - `platform` identifies which upstream surfaced the error, so one handler can branch by integration. ## Post Publishing Failures When a scheduled post fails to publish to one or more platforms, the post status reflects the outcome: | Post Status | Meaning | |-------------|---------| | `published` | All platforms published successfully. | | `partial` | Some platforms published, others failed. | | `failed` | All platforms failed to publish. | Each platform entry in the post has its own `status` and `error` fields: ```json { "post": { "status": "partial", "platforms": [ { "platform": "twitter", "status": "published", "platformPostUrl": "https://twitter.com/..." }, { "platform": "instagram", "status": "failed", "error": "Media processing failed: video too short for Reels" } ] } } ``` ### Common publishing errors | Error | Cause | Fix | |-------|-------|-----| | Token expired | OAuth token needs refresh | [Check account health](/accounts/get-all-accounts-health) and reconnect. | | Rate limited by platform | Too many posts to this platform | Wait and retry, or space out posts. | | Media processing failed | File format/size not supported by platform | Check [platform requirements](/platforms). | | Duplicate content | Platform rejected identical content | Modify the content slightly. | | Permissions missing | Account lacks required permissions | Reconnect with proper scopes. | ### Retrying failed posts For `failed` or `partial` posts, use the [retry endpoint](/posts/retry-post): ```typescript const { post } = await zernio.posts.retryPost('post_123'); ``` ```python result = client.posts.retry_post("post_123") ``` ```bash curl -X POST "https://zernio.com/api/v1/posts/post_123/retry" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Only the failed platforms are retried; already-published platforms are skipped. ## Account Health Proactively check if your connected accounts are healthy before publishing: ```typescript const health = await zernio.accounts.getAccountHealth(); ``` ```python health = client.accounts.get_account_health() ``` ```bash curl "https://zernio.com/api/v1/accounts/health" \ -H "Authorization: Bearer YOUR_API_KEY" ``` The [health check endpoint](/accounts/get-all-accounts-health) returns token validity, permissions status, and recommendations for each account. ## Webhook Reliability If you use [webhooks](/webhooks/get-webhook-settings) to track post status: - Webhooks are delivered at least once (you may receive duplicates); dedupe by event id. - Failed deliveries are retried with exponential backoff. - You can view delivery logs in the [webhooks dashboard](/webhooks). ## Best Practices - **Branch on `type` and `code`**, never on `error` text. - **Handle `429`** by respecting the `Retry-After` header. - **Treat `platform_error` upstream 4xx as caller-fixable** (bad input forwarded from the platform) and 5xx / 502 as transient. - **Monitor account health** periodically to catch token expirations early. - **Use webhooks** instead of polling for post status updates. - **Log errors with full context**, include the request body and the entire response envelope (including `platformError`) for debugging. --- # Media Uploads How to upload images, videos, and documents for use in posts import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; Posts with media perform better on every platform. Zernio uses presigned URLs for fast, direct uploads up to 5GB. ## Upload Flow 1. Request a presigned URL from `POST /v1/media/presign` 2. Upload the file directly to the returned `uploadUrl` using a PUT request 3. Use the `publicUrl` in your post's `mediaItems` array ### Step 1: Get a Presigned URL ```typescript const { uploadUrl, publicUrl } = await zernio.media.getMediaPresignedUrl({ fileName: 'photo.jpg', fileType: 'image/jpeg' }); ``` ```python result = client.media.get_media_presigned_url( filename="photo.jpg", content_type="image/jpeg" ) upload_url = result["upload_url"] public_url = result["public_url"] ``` ```bash curl -X POST "https://zernio.com/api/v1/media/presign" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fileName": "photo.jpg", "fileType": "image/jpeg" }' ``` **Response:** ```json { "uploadUrl": "https://storage.googleapis.com/...", "publicUrl": "https://storage.googleapis.com/...", "expires": "2024-01-15T11:00:00.000Z" } ``` ### Step 2: Upload the File Upload directly to the presigned URL (no auth header needed): ```typescript // Upload directly to the presigned URL (no auth needed) await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, body: fileBuffer }); ``` ```python import httpx # Upload directly to the presigned URL (no auth needed) with open("photo.jpg", "rb") as f: httpx.put(upload_url, content=f.read(), headers={"Content-Type": "image/jpeg"}) ``` ```bash curl -X PUT "UPLOAD_URL_FROM_STEP_1" \ -H "Content-Type: image/jpeg" \ --data-binary @photo.jpg ``` ### Step 3: Use in a Post Include the `publicUrl` in your post: ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { url: publicUrl, type: 'image' } ], platforms: [ { platform: 'twitter', accountId: 'acc_xyz789' } ] }); ``` ```python result = client.posts.create_post( content="Check out this photo!", media_items=[ {"url": public_url, "type": "image"} ], platforms=[ {"platform": "twitter", "accountId": "acc_xyz789"} ] ) ``` ```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": [ { "url": "PUBLIC_URL_FROM_STEP_1", "type": "image" } ], "platforms": [ { "platform": "twitter", "accountId": "acc_xyz789" } ] }' ``` See the [Presigned Upload endpoint](/media/get-media-presigned-url) for full parameter details. ## Supported Formats | Type | Formats | Max Size | |------|---------|----------| | Images | JPG, PNG, GIF, WebP | 5 GB | | Videos | MP4, MOV, AVI, WebM | 5 GB | | Documents | PDF (LinkedIn only) | 100 MB | ## Platform-Specific Media Rules Each platform has its own requirements for media. Here's a quick reference: | Platform | Max Images | Max Videos | Notes | |----------|-----------|-----------|-------| | Twitter | 4 | 1 | No mixing images and videos | | Instagram | 10 (carousel) | 1 (Reel) | Stories: single image/video | | Facebook | Multiple | 1 | Stories: single image/video | | LinkedIn | 20 images | 1 | Single PDF supported (max 300 pages) | | TikTok | 35 photos | 1 | No mixing photos and videos | | YouTube | - | 1 (required) | Optional custom thumbnail | | Pinterest | 1 | 1 | One image or one video per Pin | | Bluesky | 4 | 1 | Images auto-compressed to ~1MB | | Threads | 10 images | 1 | No video carousels | | Snapchat | 1 | 1 | Required for all post types | For detailed platform requirements, see the [Platforms](/platforms) section or the [Create Post endpoint](/posts/create-post). ## Auto-Compression Some platforms have strict file size limits. Zernio handles this automatically: - **Bluesky**: Images are automatically recompressed to stay under Bluesky's ~1MB blob limit - **YouTube Thumbnails**: Custom thumbnails via `MediaItem.thumbnail` are processed to meet YouTube's requirements ## Custom Media Per Platform You can use different media for different platforms in the same post using `customMedia` in the platform entry: ```json { "content": "Same text, different media per platform", "mediaItems": [{ "url": "default-image.jpg", "type": "image" }], "platforms": [ { "platform": "twitter", "accountId": "acc_1" }, { "platform": "instagram", "accountId": "acc_2", "customMedia": [{ "url": "square-image.jpg", "type": "image" }] } ] } ``` --- # Platform Settings Configure Twitter threads, Instagram Stories, TikTok privacy, YouTube visibility, and LinkedIn settings when posting via the Zernio API. When creating posts, you can provide platform-specific settings in the `platformSpecificData` field of each `PlatformTarget`. This allows you to customize how your content appears and behaves on each social network. --- ## Twitter/X Create multi-tweet threads with Twitter's `threadItems` array. | Property | Type | Description | |----------|------|-------------| | `threadItems` | array | Sequence of tweets in a thread. First item is the root tweet. | | `threadItems[].content` | string | Tweet text content | | `threadItems[].mediaItems` | array | Media attachments for this tweet | | `geoRestriction` | object | Restrict media visibility to specific countries. Only applies when media is attached (ignored for text-only tweets). The tweet text remains visible globally. | | `geoRestriction.countries` | string[] | Uppercase ISO 3166-1 alpha-2 codes, max 25. Example: `["US", "ES"]` | ```json { "threadItems": [ { "content": "🧵 Here's everything you need to know about our API..." }, { "content": "1/ First, authentication is simple..." }, { "content": "2/ Next, create your first post..." } ] } ``` --- ## Threads (by Meta) Similar to Twitter, create multi-post threads on Threads. | Property | Type | Description | |----------|------|-------------| | `threadItems` | array | Sequence of posts (root then replies in order) | | `threadItems[].content` | string | Post text content | | `threadItems[].mediaItems` | array | Media attachments for this post | --- ## Facebook | Property | Type | Description | |----------|------|-------------| | `contentType` | `"story"` | Publish as a Facebook Page Story (24-hour ephemeral) | | `firstComment` | string | Auto-post a first comment (feed posts only, not stories) | | `pageId` | string | Target Page ID for multi-page posting. Use `GET /v1/accounts/{id}/facebook-page` to list available pages. Uses default page if omitted. | | `geoRestriction` | object | Restrict post visibility to specific countries (hard restriction). `geoRestriction.countries`: array of uppercase ISO 3166-1 alpha-2 codes, max 25. Not supported for stories. | **Constraints:** - ❌ Cannot mix videos and images in the same post - ✅ Up to 10 images for feed posts - ✅ Stories require media (single image or video) - ⚠️ Story text captions are not displayed - ⏱️ Stories disappear after 24 hours - 📄 Use `pageId` to post to multiple Facebook Pages from the same account connection ```json { "contentType": "story", "pageId": "123456789" } ``` --- ## Instagram | Property | Type | Description | |----------|------|-------------| | `contentType` | `"story"` | Publish as an Instagram Story | | `shareToFeed` | boolean | For Reels only. When `true` (default), the Reel appears on both the Reels tab and profile feed. Set to `false` for Reels tab only. | | `collaborators` | string[] | Up to 3 usernames to invite as collaborators (feed/Reels only) | | `firstComment` | string | Auto-post a first comment (not applied to Stories) | | `trialParams` | object | Trial Reels configuration (Reels only). Trial Reels are initially shared only with non-followers. | | `trialParams.graduationStrategy` | `"MANUAL"` \| `"SS_PERFORMANCE"` | `MANUAL`: graduate via Instagram app. `SS_PERFORMANCE`: auto-graduate based on performance. | | `userTags` | array | Tag Instagram users in photos by username and position coordinates (not supported for stories or videos). For carousels, use `mediaIndex` to tag specific slides (defaults to 0). | | `userTags[].username` | string | Instagram username (@ symbol optional, auto-removed) | | `userTags[].x` | number | X coordinate from left edge (0.0–1.0) | | `userTags[].y` | number | Y coordinate from top edge (0.0–1.0) | | `userTags[].mediaIndex` | integer | Zero-based carousel slide index to tag (defaults to 0). Tags targeting video items or out-of-range indices are ignored. | | `audioName` | string | Custom name for the original audio in Reels. Replaces the default "Original Audio" label. Only applies to Reels (video posts). Can only be set once - either during creation or later from the Instagram audio page in the app. | | `thumbOffset` | integer | Millisecond offset from the start of the video to use as the Reel thumbnail. Only applies to Reels. If a custom thumbnail URL (`instagramThumbnail` in mediaItems) is provided, it takes priority. Defaults to 0 (first frame). | **Constraints:** - 📐 Feed posts require aspect ratio between **0.8** (4:5) and **1.91** (1.91:1) - 📱 9:16 images must use `contentType: "story"` - 🎠 Carousels support up to 10 media items - 🗜️ Images > 8MB auto-compressed - 📹 Story videos > 100MB auto-compressed - 🎬 Reel videos > 300MB auto-compressed - 🏷️ User tags: supported on images only (not stories/videos); for carousels, use `userTags[].mediaIndex` to tag specific slides (defaults to 0) ```json { "firstComment": "Link in bio! 🔗", "collaborators": ["brandpartner", "creator123"], "userTags": [ { "username": "friend_username", "x": 0.5, "y": 0.5 } ] } ``` --- ## LinkedIn | Property | Type | Description | |----------|------|-------------| | `organizationUrn` | string | Target LinkedIn Organization URN for multi-organization posting. Format: `urn:li:organization:123456789`. Use `GET /v1/accounts/{id}/linkedin-organizations` to list available organizations. Uses default organization if omitted. | | `firstComment` | string | Auto-post a first comment | | `disableLinkPreview` | boolean | Set `true` to disable URL previews (default: `false`) | | `geoRestriction` | object | Restrict post visibility to specific countries (hard restriction). Organization pages only, requires 300+ targeted followers. | | `geoRestriction.countries` | string[] | Uppercase ISO 3166-1 alpha-2 codes, max 25. Example: `["US", "ES"]` | **Constraints:** - ✅ Up to 20 images per post - ❌ Multi-video posts not supported - 📄 Single PDF document posts supported - 🔗 Link previews auto-generated when no media attached - 🏢 Use `organizationUrn` to post to multiple organizations from the same account connection ```json { "firstComment": "What do you think? Drop a comment below! 👇", "disableLinkPreview": false } ``` --- ## Reddit | Property | Type | Description | |----------|------|-------------| | `subreddit` | string | Target subreddit name (without "r/" prefix). Overrides the default subreddit configured on the account connection. | | `title` | string | Post title (max 300 chars). Defaults to the first line of content, truncated to 300 characters. | | `url` | string (URI) | URL for link posts. If provided (and forceSelf is not true), creates a link post instead of a text post. | | `forceSelf` | boolean | When true, creates a text/self post even when a URL or media is provided. | | `flairId` | string | Flair ID for the post (required by some subreddits). Use `GET /v1/accounts/{id}/reddit-flairs?subreddit=name` to list available flairs. | | `nativeVideo` | boolean | 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. Subreddits that block videos fall back automatically. | | `videogif` | boolean | When `true`, submit the native video as a silent looping videogif. | | `videoPosterUrl` | string (URI) | Custom poster/thumbnail. If omitted, Zernio auto-extracts the video's first frame. | --- ## Pinterest | Property | Type | Description | |----------|------|-------------| | `title` | string | Pin title (max 100 chars, defaults to first line of content) | | `boardId` | string | Target board ID (uses first available if omitted) | | `link` | string (URI) | Destination link for the pin | | `coverImageUrl` | string (URI) | Cover image for video pins | | `coverImageKeyFrameTime` | integer | Key frame time in seconds for video cover | ```json { "title": "10 Tips for Better Photography", "boardId": "board-123", "link": "https://example.com/photography-tips" } ``` --- ## YouTube | Property | Type | Description | |----------|------|-------------| | `title` | string | Video title (max 100 chars, defaults to first line of content) | | `visibility` | `"public"` \| `"private"` \| `"unlisted"` | Video visibility (default: `public`) | | `madeForKids` | boolean | COPPA compliance: Set to `true` if video is made for kids (child-directed content). Defaults to `false`. Videos marked as made for kids have restricted features (no comments, no notifications, limited ad targeting). | | `firstComment` | string | Auto-post a first comment (max 10,000 chars) | | `tags` | string[] | Tags/keywords for the video (see constraints below) | | `containsSyntheticMedia` | boolean | AI-generated content disclosure flag. Set to true if your video contains AI-generated or synthetic content that could be mistaken for real people, places, or events. This helps viewers understand when realistic content has been created or altered using AI. YouTube may add a label to videos when this is set. Added to YouTube Data API in October 2024. | `categoryId` | string | YouTube video category ID. Defaults to `"22"` (People & Blogs). Common categories: `"1"` (Film & Animation), `"2"` (Autos & Vehicles), `"10"` (Music), `"15"` (Pets & Animals), `"17"` (Sports), `"20"` (Gaming), `"22"` (People & Blogs), `"23"` (Comedy), `"24"` (Entertainment), `"25"` (News & Politics), `"26"` (Howto & Style), `"27"` (Education), `"28"` (Science & Technology). | **Tag Constraints:** - ✅ No count limit; duplicates are automatically removed - 📏 Each tag must be ≤ 100 characters - 📊 Combined total across all tags ≤ 500 characters (YouTube's limit) **Automatic Detection:** - ⏱️ Videos ≤ 3 minutes → **YouTube Shorts** - 🎬 Videos > 3 minutes → **Regular videos** - 🖼️ Custom thumbnails supported for regular videos only - ❌ Custom thumbnails NOT supported for Shorts via API - 👶 `madeForKids` defaults to `false` (not child-directed) ```json { "title": "How to Use Our API in 5 Minutes", "visibility": "public", "madeForKids": false, "firstComment": "Thanks for watching! 🙏 Subscribe for more tutorials!" } ``` --- ## TikTok > ⚠️ **Required Consent**: TikTok posts will fail without `content_preview_confirmed: true` and `express_consent_given: true`. TikTok settings are nested inside `platformSpecificData.tiktokSettings`: | Property | Type | Description | |----------|------|-------------| | `privacy_level` | string | **Required.** Must be one from your account's available options | | `allow_comment` | boolean | **Required.** Allow comments on the post | | `allow_duet` | boolean | Required for video posts | | `allow_stitch` | boolean | Required for video posts | | `content_preview_confirmed` | boolean | **Required.** Must be `true` | | `express_consent_given` | boolean | **Required.** Must be `true` | | `draft` | boolean | Send to Creator Inbox as draft instead of publishing | | `description` | string | Long-form description for photo posts (max 4000 chars) | | `video_cover_timestamp_ms` | integer | Thumbnail frame timestamp in ms (default: 1000) | | `photo_cover_index` | integer | Cover image index for carousels (0-based, default: 0) | | `auto_add_music` | boolean | Let TikTok add recommended music (photos only) | | `video_made_with_ai` | boolean | Disclose AI-generated content | | `commercial_content_type` | `"none"` \| `"brand_organic"` \| `"brand_content"` | Commercial disclosure | | `brand_partner_promote` | boolean | Brand partner promotion flag | | `is_brand_organic_post` | boolean | Brand organic post flag | | `media_type` | `"video"` \| `"photo"` | Optional override (defaults based on media items) | **Constraints:** - 📸 Photo carousels support up to 35 images - 📝 Video titles: up to 2200 characters - 📝 Photo titles: auto-truncated to 90 chars (use `description` for longer text) - 🔒 `privacy_level` must match your account's available options (no defaults) ```json { "accountId": "tiktok-012", "platformSpecificData": { "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": true, "allow_stitch": true, "content_preview_confirmed": true, "express_consent_given": true, "description": "Full description here since photo titles are limited to 90 chars..." } } } ``` --- ## Google Business Profile | Property | Type | Description | |----------|------|-------------| | `topicType` | `"STANDARD"` \| `"EVENT"` \| `"OFFER"` | Post type. Defaults to `STANDARD` if omitted. `EVENT` requires the `event` object. `OFFER` requires `offer` and optionally `event` for the offer period. | | `event` | object | Event details. Required for `EVENT`, optional for `OFFER`. See schedule format below. | | `event.title` | string | Event or offer title displayed on Google Search and Maps | | `event.schedule` | object | Date/time range. Contains `startDate`, `startTime`, `endDate`, `endTime`. Each date field accepts `{ year, month, day }` or an ISO 8601 string (e.g., `"2026-05-15T09:00:00Z"`). Time fields accept `{ hours, minutes }` or an ISO string. | | `offer` | object | Offer details for `OFFER` posts. All sub-fields optional. | | `offer.couponCode` | string | Promo/coupon code | | `offer.redeemOnlineUrl` | string (URI) | URL where the offer can be redeemed online | | `offer.termsConditions` | string | Terms and conditions text | | `locationId` | string | Target Google Business location ID for multi-location posting. Format: `locations/123456789`. Use `GET /v1/accounts/{id}/gmb-locations` to list available locations. Uses default location if omitted. | | `languageCode` | string | BCP 47 language code for the post content (e.g., `en`, `de`, `es`, `fr`). If omitted, language is auto-detected from the post text. | | `callToAction.type` | enum | `LEARN_MORE`, `BOOK`, `ORDER`, `SHOP`, `SIGN_UP`, `CALL` | | `callToAction.url` | string (URI) | Destination URL for the CTA button | **Constraints:** - ✅ Text content + single image only - ❌ Videos not supported - 🔗 CTA button drives user engagement - 📍 Posts appear on Google Search/Maps - 🗺️ Use `locationId` to post to multiple locations from the same account connection ```json { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": { "year": 2026, "month": 5, "day": 15 }, "startTime": { "hours": 9, "minutes": 0 }, "endDate": { "year": 2026, "month": 5, "day": 16 }, "endTime": { "hours": 17, "minutes": 0 } } }, "callToAction": { "type": "LEARN_MORE", "url": "https://example.com/grand-opening" } } ``` --- ## Telegram | Property | Type | Description | |----------|------|-------------| | `parseMode` | `"HTML"` \| `"Markdown"` \| `"MarkdownV2"` | Text formatting mode (default: `HTML`) | | `disableWebPagePreview` | boolean | Set `true` to disable link previews | | `disableNotification` | boolean | Send message silently (no notification sound) | | `protectContent` | boolean | Prevent forwarding and saving of the message | **Constraints:** - 📸 Up to 10 images per post (media album) - 🎬 Up to 10 videos per post (media album) - 📝 Text-only posts: up to 4096 characters - 🖼️ Media captions: up to 1024 characters - 👤 Channel posts show channel name/logo as author - 🤖 Group posts show "Zernio" as the bot author - 📊 Analytics not available via API (Telegram limitation) ```json { "parseMode": "HTML", "disableWebPagePreview": false, "disableNotification": false, "protectContent": true } ``` --- ## Snapchat | Property | Type | Description | |----------|------|-------------| | `contentType` | `"story"` \| `"saved_story"` \| `"spotlight"` | Type of Snapchat content (default: `story`) | **Content Types:** - **Story** (default): Ephemeral snap visible for 24 hours. No caption/text supported. - **Saved Story**: Permanent story saved to your Public Profile. Uses post content as title (max 45 chars). - **Spotlight**: Video for Snapchat's entertainment feed. Supports description (max 160 chars) with hashtags. **Constraints:** - 👤 Requires a Snapchat Public Profile - 🖼️ Media required for all content types (no text-only posts) - 1️⃣ Only one media item per post - 📸 Images: max 20 MB, JPEG/PNG format - 🎬 Videos: max 500 MB, MP4 format, 5-60 seconds, min 540x960px - 📐 Aspect ratio: 9:16 recommended - 🔒 Media is automatically encrypted (AES-256-CBC) before upload ```json { "contentType": "saved_story" } ``` --- ## Bluesky Bluesky doesn't require `platformSpecificData` but has important constraints: **Constraints:** - 🖼️ Up to 4 images per post - 🗜️ Images > ~1MB are automatically recompressed to meet Bluesky's blob size limit - 🔗 Link previews auto-generated when no media is attached ```json { "content": "Just posted this via the Zernio API! 🦋", "platforms": [ { "platform": "bluesky", "accountId": "bluesky-123" } ] } ``` --- ## Complete Example Here's a real-world example posting to multiple platforms with platform-specific settings: ```json { "content": "Excited to announce our new product! 🎉", "mediaItems": [ { "url": "https://example.com/product.jpg", "type": "image" } ], "platforms": [ { "platform": "twitter", "accountId": "twitter-123", "platformSpecificData": { "threadItems": [ { "content": "Excited to announce our new product! 🎉" }, { "content": "Here's what makes it special... 🧵" } ] } }, { "platform": "instagram", "accountId": "instagram-456", "platformSpecificData": { "firstComment": "Link in bio! 🔗", "collaborators": ["brandpartner"] } }, { "platform": "linkedin", "accountId": "linkedin-789", "platformSpecificData": { "firstComment": "What features would you like to see next? 👇" } }, { "platform": "tiktok", "accountId": "tiktok-012", "platformSpecificData": { "tiktokSettings": { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": false, "allow_stitch": false, "content_preview_confirmed": true, "express_consent_given": true } } }, { "platform": "youtube", "accountId": "youtube-345", "platformSpecificData": { "title": "New Product Announcement", "visibility": "public", "firstComment": "Thanks for watching! Subscribe for updates! 🔔" } }, { "platform": "googlebusiness", "accountId": "gbp-678", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "New Product Launch", "schedule": { "startDate": { "year": 2026, "month": 6, "day": 1 }, "startTime": { "hours": 10, "minutes": 0 }, "endDate": { "year": 2026, "month": 6, "day": 1 }, "endTime": { "hours": 18, "minutes": 0 } } }, "callToAction": { "type": "SHOP", "url": "https://example.com/product" } } }, { "platform": "telegram", "accountId": "telegram-901", "platformSpecificData": { "parseMode": "HTML", "disableNotification": false, "protectContent": false } }, { "platform": "snapchat", "accountId": "snapchat-234", "platformSpecificData": { "contentType": "saved_story" } } ] } ``` --- # Rate Limits API rate limits by plan, posting velocity limits, and how to handle throttling ## API Request Limits Rate limits are applied per API key, bucketed by your team's total connected social accounts. The more accounts you've scaled to, the higher your req/min ceiling. | Connected accounts | Requests per Minute | |---|---| | 0–2 (free tier) | 60 | | 3–2,000 | 600 | | 2,001+ | 1,200 | Connected accounts are counted across the whole billing team (owner + invited members). Legacy AppSumo lifetime tiers get a flat **600 req/min** regardless of tier. ### Per-Second Limits for Analytics Endpoints Analytics endpoints are rate limited on a **1-second window** rather than a 1-minute window. The per-second cap is derived from your req/min limit: ``` requests_per_second = max(2, requests_per_minute / 60) ``` Every plan has a floor of **2 req/s**, so even free-tier keys can burst at the same baseline. | Connected accounts | Requests per Minute | Requests per Second (analytics) | |---|---|---| | 0–2 (free tier) | 60 | 2 | | 3–2,000 | 600 | 10 | | 2,001+ | 1,200 | 20 | Legacy AppSumo lifetime tiers get a flat **10 req/s** on analytics endpoints regardless of tier. These endpoints use the per-second window: - `GET /api/v1/analytics` - `GET /api/v1/analytics/best-time` - `GET /api/v1/analytics/content-decay` - `GET /api/v1/analytics/daily-metrics` - `GET /api/v1/analytics/post-timeline` - `GET /api/v1/analytics/posting-frequency` All other endpoints continue to use the standard 1-minute window from the table above. ### Rate Limit Headers Every API response includes these headers: | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Your plan's requests-per-minute limit | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp when the window resets | ### Handling Rate Limits When you exceed the limit, the API returns `429 Too Many Requests`: ```json { "error": "Rate limit exceeded. Please try again later." } ``` Best approach: check `X-RateLimit-Remaining` before making requests, and back off when it's low. If you receive a `429`, wait until `X-RateLimit-Reset` before retrying. ## Posting Velocity Limits Independent of API rate limits, there are limits on how fast you can publish posts to prevent platform-level throttling: | Scenario | Limit | |----------|-------| | Posts per account | Platform-dependent cooldown between posts | | Immediate publishes | Subject to platform rate limits | | Bulk uploads | Validated and queued, published according to schedule | These limits protect your accounts from being flagged by social platforms. If a post is rejected due to velocity limiting, you'll receive an error explaining the cooldown period. ## Analytics Data Freshness Analytics endpoints have their own caching and refresh behavior rather than strict rate limits: - **Post analytics** - Cached for 60 minutes. Requests trigger a background refresh if cache is stale. No rate limit on API requests. - **Follower stats** - Refreshed once per day automatically. - **YouTube daily views** - Data has a 2-3 day delay from YouTube's Analytics API. See the [Analytics endpoints](/analytics/get-analytics) for details. ## Tips for Staying Within Limits - **Use pagination** - Don't fetch all resources at once. Use `limit` and `offset` parameters. - **Cache responses** - Store data locally instead of re-fetching frequently. - **Use webhooks** - Subscribe to [webhooks](/webhooks/get-webhook-settings) instead of polling for post status changes. - **Batch operations** - Use [bulk upload](/posts/bulk-upload-posts) instead of creating posts one at a time. --- # Analytics Google Business performance and search keyword analytics import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Analytics > **Included** — Analytics is bundled with every paid account on the [Usage plan](/pricing). **Per-post analytics are not available for Google Business Profile.** Google [deprecated the per-post insights endpoint](https://developers.google.com/my-business/content/sunset-dates) and did not ship a replacement. Per-post views, clicks and likes for GBP posts no longer exist on Google's side, not on Zernio, not anywhere. For GBP engagement data, use the **location-level Performance API** documented below. ### Performance Metrics The [Performance API](/analytics/get-google-business-performance) returns daily time-series data for your 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. ```typescript const { data } = await zernio.analytics.getGoogleBusinessPerformance({ query: { accountId: 'YOUR_ACCOUNT_ID', startDate: '2026-01-01', endDate: '2026-03-31' } }); console.log(data.metrics); // { WEBSITE_CLICKS: { total: 42, values: [...] }, CALL_CLICKS: { total: 7, values: [...] }, ... } ``` ```python response = client.analytics.get_google_business_performance( account_id='YOUR_ACCOUNT_ID', start_date='2026-01-01', end_date='2026-03-31' ) print(response.metrics) ``` ```bash curl "https://zernio.com/api/v1/analytics/googlebusiness/performance?accountId=YOUR_ACCOUNT_ID&startDate=2026-01-01&endDate=2026-03-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Available metrics: `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`. ### Search Keywords The [Search Keywords API](/analytics/get-google-business-search-keywords) returns keywords that triggered impressions for your location, aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded. Max 18 months of historical data. ```typescript const { data } = await zernio.analytics.getGoogleBusinessSearchKeywords({ query: { accountId: 'YOUR_ACCOUNT_ID', startMonth: '2026-01', endMonth: '2026-03' } }); data.keywords.forEach(k => console.log(`${k.keyword}: ${k.impressions} impressions`)); ``` ```python response = client.analytics.get_google_business_search_keywords( account_id='YOUR_ACCOUNT_ID', start_month='2026-01', end_month='2026-03' ) for k in response.keywords: print(f"{k.keyword}: {k.impressions} impressions") ``` ```bash curl "https://zernio.com/api/v1/analytics/googlebusiness/search-keywords?accountId=YOUR_ACCOUNT_ID&startMonth=2026-01&endMonth=2026-03" \ -H "Authorization: Bearer YOUR_API_KEY" ``` --- # Business Profile Management Manage location details, attributes, services, menus, media, reviews, and verifications import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Business Profile Management Beyond posting, you can manage your Google Business Profile listing directly through the API. Each subsection below covers a specific management feature. ### Food Menus Manage food menus for locations that support them (restaurants, cafes, etc.). Menu items support `price` (with currency code), `dietaryRestriction` (VEGETARIAN, VEGAN, GLUTEN_FREE), `allergen` (DAIRY, GLUTEN, SHELLFISH), `spiciness`, `servesNumPeople`, and `preparationMethods`. ```typescript // Get menus const menus = await zernio.gmbfoodmenus.getGoogleBusinessFoodMenus('YOUR_ACCOUNT_ID'); console.log('Food menus:', menus); // Update menus await zernio.gmbfoodmenus.updateGoogleBusinessFoodMenus('YOUR_ACCOUNT_ID', { menus: [{ labels: [{ displayName: 'Lunch Menu', languageCode: 'en' }], sections: [{ labels: [{ displayName: 'Appetizers' }], items: [{ labels: [{ displayName: 'Caesar Salad', description: 'Romaine, parmesan, croutons' }], attributes: { price: { currencyCode: 'USD', units: '12' }, dietaryRestriction: ['VEGETARIAN'] } }] }] }], updateMask: 'menus' }); ``` ```python # Get menus menus = client.accounts.get_google_business_food_menus("YOUR_ACCOUNT_ID") print("Food menus:", menus) # Update menus client.accounts.update_google_business_food_menus("YOUR_ACCOUNT_ID", menus=[{ "labels": [{"displayName": "Lunch Menu", "languageCode": "en"}], "sections": [{ "labels": [{"displayName": "Appetizers"}], "items": [{ "labels": [{"displayName": "Caesar Salad", "description": "Romaine, parmesan, croutons"}], "attributes": { "price": {"currencyCode": "USD", "units": "12"}, "dietaryRestriction": ["VEGETARIAN"] } }] }] }], update_mask="menus" ) ``` ```bash # Get menus curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-food-menus \ -H "Authorization: Bearer YOUR_API_KEY" # Update menus curl -X PUT https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-food-menus \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "menus": [{ "labels": [{"displayName": "Lunch Menu", "languageCode": "en"}], "sections": [{ "labels": [{"displayName": "Appetizers"}], "items": [{ "labels": [{"displayName": "Caesar Salad", "description": "Romaine, parmesan, croutons"}], "attributes": { "price": {"currencyCode": "USD", "units": "12"}, "dietaryRestriction": ["VEGETARIAN"] } }] }] }], "updateMask": "menus" }' ``` See the [GMB Food Menus API Reference](/google-business/get-google-business-food-menus) for full schema details. ### Location Details Read and update your business information including hours, special hours, description, phone numbers, and website. Use `readMask` to request specific fields and `updateMask` to update them. Available fields include `regularHours`, `specialHours`, `profile.description`, `websiteUri`, and `phoneNumbers`. ```typescript // Get location details const details = await zernio.gmblocationdetails.getGoogleBusinessLocationDetails('YOUR_ACCOUNT_ID', { readMask: 'regularHours,specialHours,profile,websiteUri' }); // Update business hours await zernio.gmblocationdetails.updateGoogleBusinessLocationDetails('YOUR_ACCOUNT_ID', { updateMask: 'regularHours', regularHours: { periods: [ { openDay: 'MONDAY', openTime: '09:00', closeDay: 'MONDAY', closeTime: '17:00' }, { openDay: 'TUESDAY', openTime: '09:00', closeDay: 'TUESDAY', closeTime: '17:00' } ] } }); ``` ```python # Get location details details = client.accounts.get_google_business_location_details("YOUR_ACCOUNT_ID", read_mask="regularHours,specialHours,profile,websiteUri" ) # Update business hours client.accounts.update_google_business_location_details("YOUR_ACCOUNT_ID", update_mask="regularHours", regular_hours={ "periods": [ {"openDay": "MONDAY", "openTime": "09:00", "closeDay": "MONDAY", "closeTime": "17:00"}, {"openDay": "TUESDAY", "openTime": "09:00", "closeDay": "TUESDAY", "closeTime": "17:00"} ] } ) ``` ```bash # Get location details curl -X GET "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-location-details?readMask=regularHours,specialHours,profile,websiteUri" \ -H "Authorization: Bearer YOUR_API_KEY" # Update business hours curl -X PUT https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-location-details \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "updateMask": "regularHours", "regularHours": { "periods": [ {"openDay": "MONDAY", "openTime": "09:00", "closeDay": "MONDAY", "closeTime": "17:00"}, {"openDay": "TUESDAY", "openTime": "09:00", "closeDay": "TUESDAY", "closeTime": "17:00"} ] } }' ``` See the [GMB Location Details API Reference](/google-business/get-google-business-location-details) for the full schema. ### Verification Check a location's verification status and drive the verification flow programmatically. `GET /gmb-verifications` returns the location's Voice of Merchant state plus its verification history. `hasVoiceOfMerchant` tells you whether the listing is verified and published; when it is `false`, `verify.hasPendingVerification` tells you whether a verification is already in progress (so you can distinguish "pending" from "not started"). To verify a location: call `POST /gmb-verifications/options` to see which methods Google offers, `POST /gmb-verifications` to start one (Google then mails a postcard, calls, or texts the business), and `POST /gmb-verifications/{verificationId}/complete` with the PIN to finish. Reviews, edits, and other listing data only surface once a location is matched to a published Google Maps place (it has a `placeId`, visible via [Location Details](#location-details)) and has Voice of Merchant. If `GET /gmb-reviews` returns an empty array for a listing that has reviews on Google, check `hasVoiceOfMerchant` here first: the connected location is usually not yet verified or not yet matched to its Maps place. `POST /gmb-verifications` is a real-world action: it triggers Google to send a postcard, call, or SMS to the business. Service-area businesses must include a `context` (service address) when fetching options, otherwise Google returns a 400. ```typescript // 1. Check verification state const { data: state } = await zernio.gmbverifications.getGoogleBusinessVerifications({ path: { accountId: 'YOUR_ACCOUNT_ID' } }); // 2. See which methods are available const { data: options } = await zernio.gmbverifications.fetchGoogleBusinessVerificationOptions({ path: { accountId: 'YOUR_ACCOUNT_ID' }, body: { languageCode: 'en-US' } }); // 3. Start a verification (e.g. by SMS) const { data: started } = await zernio.gmbverifications.startGoogleBusinessVerification({ path: { accountId: 'YOUR_ACCOUNT_ID' }, body: { method: 'SMS', languageCode: 'en-US', phoneNumber: '+14155550123' } }); // 4. Complete it with the PIN Google sent await zernio.gmbverifications.completeGoogleBusinessVerification({ path: { accountId: 'YOUR_ACCOUNT_ID', verificationId: 'VERIFICATION_ID' }, body: { pin: '123456' } }); ``` ```python # 1. Check verification state state = client.accounts.get_google_business_verifications("YOUR_ACCOUNT_ID") # 2. See which methods are available options = client.accounts.fetch_google_business_verification_options( "YOUR_ACCOUNT_ID", language_code="en-US" ) # 3. Start a verification (e.g. by SMS) started = client.accounts.start_google_business_verification( "YOUR_ACCOUNT_ID", method="SMS", language_code="en-US", phone_number="+14155550123" ) # 4. Complete it with the PIN Google sent client.accounts.complete_google_business_verification( "YOUR_ACCOUNT_ID", "VERIFICATION_ID", pin="123456" ) ``` ```bash # 1. Check verification state curl -X GET "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-verifications" \ -H "Authorization: Bearer YOUR_API_KEY" # 2. See which methods are available curl -X POST "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-verifications/options" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "languageCode": "en-US" }' # 3. Start a verification (e.g. by SMS) curl -X POST "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-verifications" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "SMS", "languageCode": "en-US", "phoneNumber": "+14155550123" }' # 4. Complete it with the PIN Google sent curl -X POST "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-verifications/VERIFICATION_ID/complete" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pin": "123456" }' ``` See the [GMB Verification API Reference](/google-business/get-google-business-verifications) for the full schema. ### Media (Photos) Upload, list, and delete photos for your Google Business Profile listing. Photo categories: `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `FOOD_AND_DRINK`, `MENU`, `PRODUCT`, `TEAMS`, `ADDITIONAL`. ```typescript // List photos const media = await zernio.gmbmedia.listGoogleBusinessMedia('YOUR_ACCOUNT_ID'); // Upload a photo await zernio.gmbmedia.createGoogleBusinessMedia('YOUR_ACCOUNT_ID', { sourceUrl: 'https://example.com/photos/interior.jpg', description: 'Dining area with outdoor seating', category: 'INTERIOR' }); ``` ```python # List photos media = client.accounts.list_google_business_media("YOUR_ACCOUNT_ID") # Upload a photo client.accounts.create_google_business_media("YOUR_ACCOUNT_ID", source_url="https://example.com/photos/interior.jpg", description="Dining area with outdoor seating", category="INTERIOR" ) ``` ```bash # List photos curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-media \ -H "Authorization: Bearer YOUR_API_KEY" # Upload a photo curl -X POST https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-media \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceUrl": "https://example.com/photos/interior.jpg", "description": "Dining area with outdoor seating", "category": "INTERIOR" }' ``` See the [GMB Media API Reference](/google-business/list-google-business-media) for full details. ### Attributes Manage amenities and services like delivery, Wi-Fi, outdoor seating, and payment types. Available attributes vary by business category. Common ones include `has_dine_in`, `has_takeout`, `has_delivery`, `has_wifi`, `has_outdoor_seating`, and `pay_credit_card_types_accepted`. ```typescript // Get attributes const attrs = await zernio.gmbattributes.getGoogleBusinessAttributes('YOUR_ACCOUNT_ID'); // Update attributes await zernio.gmbattributes.updateGoogleBusinessAttributes('YOUR_ACCOUNT_ID', { attributes: [ { name: 'has_delivery', values: [true] }, { name: 'has_outdoor_seating', values: [true] } ], attributeMask: 'has_delivery,has_outdoor_seating' }); ``` ```python # Get attributes attrs = client.accounts.get_google_business_attributes("YOUR_ACCOUNT_ID") # Update attributes client.accounts.update_google_business_attributes("YOUR_ACCOUNT_ID", attributes=[ {"name": "has_delivery", "values": [True]}, {"name": "has_outdoor_seating", "values": [True]} ], attribute_mask="has_delivery,has_outdoor_seating" ) ``` ```bash # Get attributes curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-attributes \ -H "Authorization: Bearer YOUR_API_KEY" # Update attributes curl -X PUT https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-attributes \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "attributes": [ {"name": "has_delivery", "values": [true]}, {"name": "has_outdoor_seating", "values": [true]} ], "attributeMask": "has_delivery,has_outdoor_seating" }' ``` See the [GMB Attributes API Reference](/google-business/get-google-business-attributes) for full details. ### Place Actions Manage booking, ordering, and reservation buttons that appear on your listing. Action types: `APPOINTMENT`, `ONLINE_APPOINTMENT`, `DINING_RESERVATION`, `FOOD_ORDERING`, `FOOD_DELIVERY`, `FOOD_TAKEOUT`, `SHOP_ONLINE`. ```typescript // List place actions const actions = await zernio.gmbplaceactions.listGoogleBusinessPlaceActions('YOUR_ACCOUNT_ID'); // Create a place action await zernio.gmbplaceactions.createGoogleBusinessPlaceAction('YOUR_ACCOUNT_ID', { uri: 'https://order.ubereats.com/mybusiness', placeActionType: 'FOOD_ORDERING' }); ``` ```python # List place actions actions = client.accounts.list_google_business_place_actions("YOUR_ACCOUNT_ID") # Create a place action client.accounts.create_google_business_place_action("YOUR_ACCOUNT_ID", uri="https://order.ubereats.com/mybusiness", place_action_type="FOOD_ORDERING" ) ``` ```bash # List place actions curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-place-actions \ -H "Authorization: Bearer YOUR_API_KEY" # Create a place action curl -X POST https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-place-actions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "uri": "https://order.ubereats.com/mybusiness", "placeActionType": "FOOD_ORDERING" }' ``` See the [GMB Place Actions API Reference](/google-business/list-google-business-place-actions) for full details. ### Services Get and manage the services offered by a Google Business Profile location. Services can be structured (using a predefined `serviceTypeId`) or free-form (custom label), with an optional `price`. > **Note:** Google's API requires full replacement of the service list. Use `PUT /v1/accounts/{accountId}/gmb-services` to replace the entire list. ```typescript // Get services const services = await zernio.gmbservices.getGoogleBusinessServices('YOUR_ACCOUNT_ID'); console.log('Services:', services); // Replace services (full replacement) await zernio.gmbservices.updateGoogleBusinessServices('YOUR_ACCOUNT_ID', { serviceItems: [ { freeFormServiceItem: { category: 'categories/gcid:plumber', label: { displayName: 'Pipe Repair', description: 'Emergency and scheduled pipe repair' } }, price: { currencyCode: 'USD', units: '150' } } ] }); ``` ```python # Get services services = client.gmb_services.get_google_business_services("YOUR_ACCOUNT_ID") print("Services:", services) # Replace services (full replacement) client.gmb_services.update_google_business_services("YOUR_ACCOUNT_ID", service_items=[ { "freeFormServiceItem": { "category": "categories/gcid:plumber", "label": { "displayName": "Pipe Repair", "description": "Emergency and scheduled pipe repair" } }, "price": {"currencyCode": "USD", "units": "150"} } ] ) ``` ```bash # Get services curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-services \ -H "Authorization: Bearer YOUR_API_KEY" # Replace services (full replacement) curl -X PUT https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-services \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "serviceItems": [ { "freeFormServiceItem": { "category": "categories/gcid:plumber", "label": { "displayName": "Pipe Repair", "description": "Emergency and scheduled pipe repair" } }, "price": { "currencyCode": "USD", "units": "150" } } ] }' ``` ```typescript // List place actions const actions = await zernio.gmbplaceactions.listGoogleBusinessPlaceActions('YOUR_ACCOUNT_ID'); // Create a place action await zernio.gmbplaceactions.createGoogleBusinessPlaceAction('YOUR_ACCOUNT_ID', { uri: 'https://order.ubereats.com/mybusiness', placeActionType: 'FOOD_ORDERING' }); ``` ```python # List place actions actions = client.accounts.list_google_business_place_actions("YOUR_ACCOUNT_ID") # Create a place action client.accounts.create_google_business_place_action("YOUR_ACCOUNT_ID", uri="https://order.ubereats.com/mybusiness", place_action_type="FOOD_ORDERING" ) ``` ```bash # List place actions curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-place-actions \ -H "Authorization: Bearer YOUR_API_KEY" # Create a place action curl -X POST https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-place-actions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "uri": "https://order.ubereats.com/mybusiness", "placeActionType": "FOOD_ORDERING" }' ``` See the [GMB Place Actions API Reference](/google-business/list-google-business-place-actions) for full details. #### Update an existing place action Use `PATCH /v1/accounts/{accountId}/gmb-place-actions` to update an existing action link (change `uri` and/or `placeActionType`). Only fields included in the request body are updated. ```typescript await zernio.gmbplaceactions.updateGoogleBusinessPlaceAction('YOUR_ACCOUNT_ID', { name: 'locations/123/placeActionLinks/456', uri: 'https://order.doordash.com/joespizza' }); ``` ```python client.accounts.update_google_business_place_action("YOUR_ACCOUNT_ID", name="locations/123/placeActionLinks/456", uri="https://order.doordash.com/joespizza" ) ``` ```bash curl -X PATCH "https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-place-actions" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "locations/123/placeActionLinks/456", "uri": "https://order.doordash.com/joespizza" }' ``` --- # Event & Offer Posts Create event and offer posts via topicType import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Event and Offer Posts (topicType) Google Business Profile supports three post types via `platformSpecificData.topicType`: | topicType | When to use | Required fields | |----------|-------------|----------------| | `STANDARD` | Regular updates | None | | `EVENT` | Announcements with a date range (grand openings, live music, workshops) | `event` | | `OFFER` | Promotions and discounts | `offer` | > **Note:** For `EVENT` posts, Google returns a 400 error if `event` is omitted. ### Event Post Example ```typescript const { post } = await zernio.posts.createPost({ content: 'Join us for our Grand Opening Weekend! Food, music, and giveaways.', mediaItems: [ { type: 'image', url: 'https://example.com/grand-opening.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { topicType: 'EVENT', event: { title: 'Grand Opening Weekend', schedule: { startDate: { year: 2026, month: 5, day: 15 }, startTime: { hours: 9, minutes: 0 }, endDate: { year: 2026, month: 5, day: 16 }, endTime: { hours: 17, minutes: 0 } } }, callToAction: { type: 'LEARN_MORE', url: 'https://mybusiness.com/grand-opening' } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Join us for our Grand Opening Weekend! Food, music, and giveaways.", media_items=[ {"type": "image", "url": "https://example.com/grand-opening.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": {"year": 2026, "month": 5, "day": 15}, "startTime": {"hours": 9, "minutes": 0}, "endDate": {"year": 2026, "month": 5, "day": 16}, "endTime": {"hours": 17, "minutes": 0} } }, "callToAction": { "type": "LEARN_MORE", "url": "https://mybusiness.com/grand-opening" } } }], 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": "Join us for our Grand Opening Weekend! Food, music, and giveaways.", "mediaItems": [ {"type": "image", "url": "https://example.com/grand-opening.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": {"year": 2026, "month": 5, "day": 15}, "startTime": {"hours": 9, "minutes": 0}, "endDate": {"year": 2026, "month": 5, "day": 16}, "endTime": {"hours": 17, "minutes": 0} } }, "callToAction": { "type": "LEARN_MORE", "url": "https://mybusiness.com/grand-opening" } } }], "publishNow": true }' ``` ### Offer Post Example ```typescript const { post } = await zernio.posts.createPost({ content: 'This weekend only: Save 20% on all services. Use code SAVE20.', mediaItems: [ { type: 'image', url: 'https://example.com/save20.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { topicType: 'OFFER', offer: { offerType: 'OFFER', redeemOnlineUrl: 'https://mybusiness.com/redeem', couponCode: 'SAVE20', termsConditions: 'Valid Fri–Sun only. One per customer.' }, callToAction: { type: 'SHOP', url: 'https://mybusiness.com/services' } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="This weekend only: Save 20% on all services. Use code SAVE20.", media_items=[ {"type": "image", "url": "https://example.com/save20.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "OFFER", "offer": { "offerType": "OFFER", "redeemOnlineUrl": "https://mybusiness.com/redeem", "couponCode": "SAVE20", "termsConditions": "Valid Fri–Sun only. One per customer." }, "callToAction": { "type": "SHOP", "url": "https://mybusiness.com/services" } } }], 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": "This weekend only: Save 20% on all services. Use code SAVE20.", "mediaItems": [ {"type": "image", "url": "https://example.com/save20.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "OFFER", "offer": { "offerType": "OFFER", "redeemOnlineUrl": "https://mybusiness.com/redeem", "couponCode": "SAVE20", "termsConditions": "Valid Fri–Sun only. One per customer." }, "callToAction": { "type": "SHOP", "url": "https://mybusiness.com/services" } } }], "publishNow": true }' ``` ### Language Code Example By default, post language is auto-detected from text. If auto-detection may be inaccurate (very short posts, mixed-language content, transliterated text), set `languageCode` explicitly. ```typescript const { post } = await zernio.posts.createPost({ content: 'Diese Woche: 20% Rabatt auf alle Services.', mediaItems: [ { type: 'image', url: 'https://example.com/promo.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { languageCode: 'de' } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Diese Woche: 20% Rabatt auf alle Services.", media_items=[ {"type": "image", "url": "https://example.com/promo.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "languageCode": "de" } }], 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": "Diese Woche: 20% Rabatt auf alle Services.", "mediaItems": [ {"type": "image", "url": "https://example.com/promo.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "languageCode": "de" } }], "publishNow": true }' ``` --- # Inbox Reviews and Q&A in the inbox import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Google Business supports reviews management with real-time notifications. ### Reviews | Feature | Supported | |---------|-----------| | List reviews | ✅ | | Reply to reviews | ✅ | | Delete reply | ✅ | | Real-time webhooks | ✅ (`review.new`, `review.updated`) | ### Limitations - **No DMs** - Google Business does not have a messaging system accessible via API - **No comments** - Posts on Google Business do not support comments See [Reviews API Reference](/reviews/list-inbox-reviews) for endpoint details. ### Batch get reviews (multiple locations) Use `POST /v1/accounts/{accountId}/gmb-reviews/batch` to fetch reviews across multiple locations in a single request. Reviews are grouped by location in the response. ```bash curl -X POST https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-reviews/batch \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "locationNames": [ "accounts/123/locations/456", "accounts/123/locations/789" ], "pageSize": 50 }' ``` ```javascript const { data } = await zernio.accounts.batchGetGoogleBusinessReviews('YOUR_ACCOUNT_ID', { locationNames: [ 'accounts/123/locations/456', 'accounts/123/locations/789' ], pageSize: 50 }); data.locationReviews.forEach(lr => { console.log(lr.locationName, lr.totalReviewCount, lr.averageRating); }); ``` ```python data = client.accounts.batch_get_google_business_reviews("YOUR_ACCOUNT_ID", location_names=[ "accounts/123/locations/456", "accounts/123/locations/789" ], page_size=50 ) for lr in data["locationReviews"]: print(lr["locationName"], lr.get("totalReviewCount"), lr.get("averageRating")) ``` --- # Google Business Schedule and automate Google Business Profile posts with Zernio API import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { Cards, Card } from 'fumadocs-ui/components/card'; import { PlatformCapabilities } from '@/components/platform-capabilities'; ## Quick Reference ## Before You Start Google Business Profile is **not social media** -- it's local SEO. Posts appear on Google Search, Google Maps, and Google Knowledge Panel. They contribute to local search ranking. Posts are visible for about 7 days before being archived. Post weekly minimum. - Requires a **verified** Google Business Profile - Posts appear in Google Search + Maps (not a social feed) - Videos are **not supported** - No text-only posts via API (media or CTA recommended for visibility) ## Related Endpoints - [Connect Google Business Account](/guides/connecting-accounts) - OAuth flow - [Create Post](/posts/create-post) - Post creation and scheduling - [Upload Media](/guides/media-uploads) - Image uploads - [GMB Reviews](/google-business/get-google-business-reviews) - Manage reviews - [GMB Food Menus](/google-business/get-google-business-food-menus) - Manage food menus - [GMB Location Details](/google-business/get-google-business-location-details) - Hours, description, contact info - [GMB Verification](/google-business/get-google-business-verifications) - Check status, start and complete verification - [GMB Media](/google-business/list-google-business-media) - Photos management - [GMB Attributes](/google-business/get-google-business-attributes) - Amenities and services - [GMB Services](/google-business/get-google-business-services) - List and replace services - [GMB Place Actions](/google-business/list-google-business-place-actions) - Booking and ordering links - [Performance Metrics](/analytics/get-google-business-performance) - Daily impressions, clicks, calls, directions, bookings - [Search Keywords](/analytics/get-google-business-search-keywords) - Keywords that triggered impressions - [Reviews](/reviews/list-inbox-reviews) ## In This Section --- # Multi-Location Posting Post to many business locations at once import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Multi-Location Posting If your connected Google Business account manages multiple locations, you can post to different locations from the same account connection. ### List Available Locations First, retrieve the list of locations you can post to: ```typescript const locations = await zernio.connect.getGmbLocations('YOUR_ACCOUNT_ID'); console.log('Available locations:', locations); ``` ```python locations = client.connect.get_gmb_locations("YOUR_ACCOUNT_ID") print("Available locations:", locations) ``` ```bash curl -X GET https://zernio.com/api/v1/accounts/YOUR_ACCOUNT_ID/gmb-locations \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Post to Multiple Locations Use the same `accountId` multiple times with different `locationId` values: ```typescript const { post } = await zernio.posts.createPost({ content: 'Now open at all locations! Visit us today.', mediaItems: [ { type: 'image', url: 'https://example.com/store.jpg' } ], platforms: [ { platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { locationId: 'locations/111111111' } }, { platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { locationId: 'locations/222222222' } } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Now open at all locations! Visit us today.", media_items=[ {"type": "image", "url": "https://example.com/store.jpg"} ], platforms=[ { "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"locationId": "locations/111111111"} }, { "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"locationId": "locations/222222222"} } ], 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": "Now open at all locations! Visit us today.", "mediaItems": [ {"type": "image", "url": "https://example.com/store.jpg"} ], "platforms": [ { "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "locationId": "locations/111111111" } }, { "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "locationId": "locations/222222222" } } ], "publishNow": true }' ``` The `locationId` format is `locations/` followed by the location ID number. --- # Posts & Content Types Create updates, offers, CTAs, and other Google Business post types import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Start Create a Google Business Profile post with an image: ```typescript const { post } = await zernio.posts.createPost({ content: 'We are open this holiday weekend! Stop by for our special seasonal menu.', mediaItems: [ { type: 'image', url: 'https://example.com/holiday-special.jpg' } ], platforms: [ { platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Posted to Google Business!', post._id); ``` ```python result = client.posts.create_post( content="We are open this holiday weekend! Stop by for our special seasonal menu.", media_items=[ {"type": "image", "url": "https://example.com/holiday-special.jpg"} ], platforms=[ {"platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Posted to Google Business! {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 are open this holiday weekend! Stop by for our special seasonal menu.", "mediaItems": [ {"type": "image", "url": "https://example.com/holiday-special.jpg"} ], "platforms": [ {"platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text + Image Post The most common and recommended post type. A single image with text. No `contentType` field is needed -- this is the default when media is included. ```typescript const { post } = await zernio.posts.createPost({ content: 'Fresh seasonal menu available now! Visit us to try our new dishes.', mediaItems: [ { type: 'image', url: 'https://example.com/seasonal-menu.jpg' } ], platforms: [ { platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Fresh seasonal menu available now! Visit us to try our new dishes.", media_items=[ {"type": "image", "url": "https://example.com/seasonal-menu.jpg"} ], platforms=[ {"platform": "googlebusiness", "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": "Fresh seasonal menu available now! Visit us to try our new dishes.", "mediaItems": [ {"type": "image", "url": "https://example.com/seasonal-menu.jpg"} ], "platforms": [ {"platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Text-Only Post Text-only posts are supported but have lower visibility on Google Search and Maps. Adding an image or CTA is recommended. ```typescript const { post } = await zernio.posts.createPost({ content: 'Happy Friday! We are offering 20% off all services this weekend. Mention this post when you visit!', platforms: [ { platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); ``` ```python result = client.posts.create_post( content="Happy Friday! We are offering 20% off all services this weekend. Mention this post when you visit!", platforms=[ {"platform": "googlebusiness", "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": "Happy Friday! We are offering 20% off all services this weekend. Mention this post when you visit!", "platforms": [ {"platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Post with CTA Button Add a call-to-action button to drive traffic. The CTA appears as a prominent button below the post content. ```typescript const { post } = await zernio.posts.createPost({ content: 'Book your appointment today! Limited spots available this week.', mediaItems: [ { type: 'image', url: 'https://example.com/booking.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { callToAction: { type: 'BOOK', url: 'https://mybusiness.com/book' } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Book your appointment today! Limited spots available this week.", media_items=[ {"type": "image", "url": "https://example.com/booking.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "callToAction": { "type": "BOOK", "url": "https://mybusiness.com/book" } } }], 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": "Book your appointment today! Limited spots available this week.", "mediaItems": [ {"type": "image", "url": "https://example.com/booking.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "callToAction": { "type": "BOOK", "url": "https://mybusiness.com/book" } } }], "publishNow": true }' ``` **Available CTA Types:** | Type | Description | Best For | |------|-------------|----------| | `LEARN_MORE` | Link to more information | Articles, about pages | | `BOOK` | Booking/reservation link | Services, appointments | | `ORDER` | Online ordering link | Restaurants, food | | `SHOP` | E-commerce link | Retail, products | | `SIGN_UP` | Registration link | Events, newsletters | | `CALL` | Phone call action | Contact, inquiries | ### Event Post Promote an event with a title, date range, and optional CTA. Events appear prominently on your Google listing with the event title and schedule. ```typescript const { post } = await zernio.posts.createPost({ content: 'Join us for our Grand Opening Weekend! Free samples and live music.', mediaItems: [ { type: 'image', url: 'https://example.com/grand-opening.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { topicType: 'EVENT', event: { title: 'Grand Opening Weekend', schedule: { startDate: { year: 2026, month: 5, day: 15 }, startTime: { hours: 9, minutes: 0 }, endDate: { year: 2026, month: 5, day: 16 }, endTime: { hours: 17, minutes: 0 } } }, callToAction: { type: 'LEARN_MORE', url: 'https://mybusiness.com/grand-opening' } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Join us for our Grand Opening Weekend! Free samples and live music.", media_items=[ {"type": "image", "url": "https://example.com/grand-opening.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": {"year": 2026, "month": 5, "day": 15}, "startTime": {"hours": 9, "minutes": 0}, "endDate": {"year": 2026, "month": 5, "day": 16}, "endTime": {"hours": 17, "minutes": 0} } }, "callToAction": { "type": "LEARN_MORE", "url": "https://mybusiness.com/grand-opening" } } }], 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": "Join us for our Grand Opening Weekend! Free samples and live music.", "mediaItems": [ {"type": "image", "url": "https://example.com/grand-opening.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": {"year": 2026, "month": 5, "day": 15}, "startTime": {"hours": 9, "minutes": 0}, "endDate": {"year": 2026, "month": 5, "day": 16}, "endTime": {"hours": 17, "minutes": 0} } }, "callToAction": { "type": "LEARN_MORE", "url": "https://mybusiness.com/grand-opening" } } }], "publishNow": true }' ``` Schedule dates also accept ISO 8601 strings, which are converted automatically: ```json { "topicType": "EVENT", "event": { "title": "Grand Opening Weekend", "schedule": { "startDate": "2026-05-15T00:00:00Z", "startTime": "2026-05-15T09:00:00Z", "endDate": "2026-05-16T00:00:00Z", "endTime": "2026-05-16T17:00:00Z" } } } ``` ### Offer Post Promote a deal with coupon codes, redemption URLs, and terms. Offer posts include an event object that sets the offer period and title displayed on Google. ```typescript const { post } = await zernio.posts.createPost({ content: 'Holiday sale! 20% off everything through the end of December.', mediaItems: [ { type: 'image', url: 'https://example.com/holiday-sale.jpg' } ], platforms: [{ platform: 'googlebusiness', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { topicType: 'OFFER', event: { title: 'Holiday Sale - 20% Off', schedule: { startDate: { year: 2026, month: 12, day: 1 }, endDate: { year: 2026, month: 12, day: 31 } } }, offer: { couponCode: 'HOLIDAY20', redeemOnlineUrl: 'https://mybusiness.com/shop', termsConditions: 'Valid in-store and online. Cannot be combined with other offers.' } } }], publishNow: true }); ``` ```python result = client.posts.create_post( content="Holiday sale! 20% off everything through the end of December.", media_items=[ {"type": "image", "url": "https://example.com/holiday-sale.jpg"} ], platforms=[{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "OFFER", "event": { "title": "Holiday Sale - 20% Off", "schedule": { "startDate": {"year": 2026, "month": 12, "day": 1}, "endDate": {"year": 2026, "month": 12, "day": 31} } }, "offer": { "couponCode": "HOLIDAY20", "redeemOnlineUrl": "https://mybusiness.com/shop", "termsConditions": "Valid in-store and online. Cannot be combined with other offers." } } }], 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": "Holiday sale! 20% off everything through the end of December.", "mediaItems": [ {"type": "image", "url": "https://example.com/holiday-sale.jpg"} ], "platforms": [{ "platform": "googlebusiness", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "topicType": "OFFER", "event": { "title": "Holiday Sale - 20% Off", "schedule": { "startDate": {"year": 2026, "month": 12, "day": 1}, "endDate": {"year": 2026, "month": 12, "day": 31} } }, "offer": { "couponCode": "HOLIDAY20", "redeemOnlineUrl": "https://mybusiness.com/shop", "termsConditions": "Valid in-store and online. Cannot be combined with other offers." } } }], "publishNow": true }' ``` **Offer fields** (all optional, but at least one recommended): | Field | Type | Description | |-------|------|-------------| | `couponCode` | string | Promo code customers can use | | `redeemOnlineUrl` | string (URI) | URL where the offer can be redeemed | | `termsConditions` | string | Terms and conditions text | --- # Media & Limits Media requirements, platform-specific fields, and common errors import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Media Requirements | Property | Requirement | |----------|-------------| | **Max images** | 1 per post | | **Formats** | JPEG, PNG (WebP auto-converted) | | **Max file size** | 5 MB | | **Min dimensions** | 400 x 300 px | | **Recommended** | 1200 x 900 px (4:3) | Google may crop images. Use 4:3 aspect ratio for best results. ## Platform-Specific Fields All fields are set inside `platformSpecificData` on the platform entry. | Field | Type | Description | |-------|------|-------------| | `topicType` | `"STANDARD"` \| `"EVENT"` \| `"OFFER"` | Post type. Defaults to `STANDARD`. `EVENT` requires the `event` object. `OFFER` requires `offer` and optionally `event` for the offer period. | | `event` | object | Event details. Required for `EVENT`, optional for `OFFER` (sets offer period). Contains `title` (string) and `schedule` with `startDate`, `startTime`, `endDate`, `endTime`. Dates accept `{ year, month, day }` objects or ISO 8601 strings. | | `offer` | object | Offer details for `OFFER` posts. Fields: `couponCode`, `redeemOnlineUrl`, `termsConditions` (all optional). | | `locationId` | string | For multi-location businesses. Format: `locations/111111111`. Get locations via `GET /v1/accounts/{accountId}/gmb-locations`. If omitted, posts to default location. | | `languageCode` | string | BCP 47 language code (e.g., `en`, `de`). Sets metadata only -- does not translate content. | | `topicType` | `STANDARD` \| `EVENT` \| `OFFER` \| `ALERT` | Post type. `STANDARD` is a regular update. `EVENT` requires `event`. `OFFER` requires `offer`. Defaults to `STANDARD` if omitted. | | `callToAction` | `{ type, url }` | CTA button. `type`: `LEARN_MORE`, `BOOK`, `ORDER`, `SHOP`, `SIGN_UP`, `CALL`. `url`: valid HTTPS URL. | | `event` | object | Event details. Required when `topicType` is `EVENT`. Event schedule accepts both ISO 8601 strings (e.g. `2026-04-15T09:00:00Z`) and Google's native `{ year, month, day }` objects. | | `offer` | object | Offer details. Required when `topicType` is `OFFER`. | ## Media URL Requirements | Requirement | Details | |-------------|---------| | **Public URL** | Must be publicly accessible | | **HTTPS** | Secure URLs only | | **No redirects** | Direct link to image | | **No auth required** | Cannot require login | ``` https://mybucket.s3.amazonaws.com/image.jpg (valid) https://example.com/images/post.png (valid) https://example.com/image?token=abc (invalid - auth required) http://example.com/image.jpg (invalid - not HTTPS) ``` ## What You Can't Do These features are not available through Google Business Profile's API: - Post videos - Respond to Q&A (deprecated by Google, replaced by AI-powered "Ask Maps") - Manage service areas - Manage business categories ## Common Errors Google Business has a **6.5% failure rate** across Zernio's platform (557 failures out of 8,529 attempts). Here are the most frequent errors and how to fix them: | Error | Meaning | Fix | |-------|---------|-----| | "Image not found" | Image URL is inaccessible or requires authentication | Verify URL is publicly accessible. Ensure HTTPS. Test URL in an incognito browser. | | "Invalid image format" | Unsupported file format or corrupted file | Use JPEG or PNG only. GIF is not supported. Re-export the image if corrupted. | | "Image too small" | Image dimensions below minimum | Use at least 400 x 300 px. Recommended: 1200 x 900 px. | | Post not appearing | Post may be pending review or account not verified | Posts may take 24-48 hours to appear. Check Google Business Console for approval status. Ensure account is verified. | | CTA not working | Invalid or inaccessible URL | Verify URL is valid and accessible. Use HTTPS. Avoid shortened URLs. | --- # Ad Comments Read and manage comments on your Meta ads import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Ad Comments Comments on ad creatives (including dark posts, ads created directly in Meta Ads Manager that never went live organically on the Page feed) aren't reachable through the standard [`GET /v1/inbox/comments/{postId}`](/comments/list-inbox-comments) endpoint, because dark posts don't live in Zernio's post database and Meta's public media-level Graph API endpoints don't resolve them. Use [`GET /v1/ads/{adId}/comments`](/ads/get-ad-comments) instead. It resolves the creative's underlying story via the Marketing API (`effective_object_story_id` on Facebook, `effective_instagram_media_id` on Instagram), then fetches the comments from the Graph API. The response matches the inbox endpoint shape so you can reuse the same rendering logic. ```typescript const { comments, meta } = await zernio.ads.getAdComments({ path: { adId: 'AD_ID' }, query: { limit: 50 }, }); console.log(meta.platform); // 'facebook' | 'instagram' console.log(meta.effectiveStoryId); // underlying post ID for (const c of comments) { console.log(c.from.name, c.message); } ``` ```python res = client.ads.get_ad_comments( ad_id="AD_ID", limit=50, ) for c in res["comments"]: print(c["from"]["name"], c["message"]) ``` ```bash curl "https://api.zernio.com/v1/ads/AD_ID/comments?limit=50" \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` Returns `ad_not_commentable` when the creative format does not expose an underlying commentable post (Story ads, Dynamic Product Ads). Returns `feature_not_available` for non-Meta ad platforms. --- # Custom Audiences Build custom and lookalike audiences import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Custom Audiences Create a Lookalike Audience: ```typescript const audience = await zernio.adaudiences.createAdAudience({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', type: 'lookalike', name: 'LAL 1% of US customers', sourceAudienceId: '6123456789', country: 'US', ratio: 0.01, }, }); ``` ```python audience = client.ad_audiences.create_ad_audience( account_id="acc_metaads_123", ad_account_id="act_1234567890", type="lookalike", name="LAL 1% of US customers", source_audience_id="6123456789", country="US", ratio=0.01, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/audiences" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_metaads_123", "adAccountId": "act_1234567890", "type": "lookalike", "name": "LAL 1% of US customers", "sourceAudienceId": "6123456789", "country": "US", "ratio": 0.01 }' ``` Add users to a customer list (SHA-256 hashed automatically): ```typescript await zernio.adaudiences.addUsersToAdAudience({ path: { audienceId: 'AUDIENCE_ID' }, body: { users: [ { email: 'user@example.com' }, { phone: '+14155551234' }, ], }, }); ``` ```python client.ad_audiences.add_users_to_ad_audience( audience_id="AUDIENCE_ID", users=[ {"email": "user@example.com"}, {"phone": "+14155551234"}, ], ) ``` ```bash 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": "user@example.com" }, { "phone": "+14155551234" } ] }' ``` --- # Boost a Post Turn an existing organic post into a paid ad import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Boost a Post ```typescript const ad = await zernio.ads.boostPost({ body: { postId: 'POST_ID', accountId: 'ACCOUNT_ID', adAccountId: 'act_1234567890', name: 'Spring launch boost', goal: 'traffic', budget: { amount: 40, type: 'daily' }, schedule: { startDate: '2026-04-20', endDate: '2026-04-27' }, targeting: { ageMin: 25, ageMax: 45, countries: ['US', 'CA'], interests: [{ id: '6003139266461', name: 'DevOps' }] } }}); ``` ```python ad = client.ads.boost_post( post_id="POST_ID", account_id="ACCOUNT_ID", ad_account_id="act_1234567890", name="Spring launch boost", goal="traffic", budget={"amount": 40, "type": "daily"}, schedule={"startDate": "2026-04-20", "endDate": "2026-04-27"}, targeting={"ageMin": 25, "ageMax": 45, "countries": ["US", "CA"]}, ) ``` ```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": "act_1234567890", "name": "Spring launch boost", "goal": "traffic", "budget": { "amount": 40, "type": "daily" }, "schedule": { "startDate": "2026-04-20", "endDate": "2026-04-27" }, "targeting": { "ageMin": 25, "ageMax": 45, "countries": ["US", "CA"] } }' ``` --- # Campaigns & Ad Sets Create standalone campaigns and manage campaigns, ad sets, and budgets import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Create a Standalone Campaign ```typescript const ad = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring sale - US Feed', goal: 'traffic', budgetAmount: 75, budgetType: 'daily', headline: 'Spring Sale - 30% off', body: 'Limited time. Upgrade today.', imageUrl: 'https://cdn.example.com/spring.jpg', callToAction: 'SHOP_NOW', linkUrl: 'https://example.com/spring', countries: ['US'], ageMin: 25, ageMax: 55, }}); ``` ```python ad = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", name="Spring sale - US Feed", goal="traffic", budget_amount=75, budget_type="daily", headline="Spring Sale - 30% off", body="Limited time. Upgrade today.", image_url="https://cdn.example.com/spring.jpg", call_to_action="SHOP_NOW", link_url="https://example.com/spring", countries=["US"], age_min=25, age_max=55, ) ``` ```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_metaads_123", "adAccountId": "act_1234567890", "name": "Spring sale - US Feed", "goal": "traffic", "budgetAmount": 75, "budgetType": "daily", "headline": "Spring Sale - 30% off", "body": "Limited time. Upgrade today.", "imageUrl": "https://cdn.example.com/spring.jpg", "callToAction": "SHOP_NOW", "linkUrl": "https://example.com/spring", "countries": ["US"], "ageMin": 25, "ageMax": 55, "interests": [{ "id": "6003139266461", "name": "DevOps" }] }' ``` For `goal: "conversions"`, `"lead_conversion"`, `"lead_generation"`, `"app_promotion"`, or `"catalog_sales"`, see [Conversion campaigns and promoted objects](/platforms/meta-ads/conversion-campaigns#conversion-campaigns-and-promoted-objects) below — Meta requires a `promotedObject` (Pixel + event, Page, or App) on the ad set for those optimization goals. **`/v1/ads/create` uses a flat body** (every field at the top level). The `/v1/ads/boost` endpoint is different, it uses nested `budget`, `schedule`, and `targeting` objects. Don't mix the two shapes. Platform is inferred from `accountId`, so no `platform` field in the body. ## Manage Campaigns & Ad Sets Zernio exposes the full Meta campaign tree with CBO/ABO awareness, review-state, and budget-level routing, enough to build a Meta Ads Manager replacement on top of `GET /v1/ads/tree` and the write endpoints below. ### Bid strategy Meta's full bid-strategy enum is supported on writes (`POST /v1/ads/create`, `POST /v1/ads/boost`, `PUT /v1/ads/ad-sets/{adSetId}`, `PUT /v1/ads/campaigns/{campaignId}`) and surfaced on reads (`GET /v1/ads/tree`, `/v1/ads/campaigns`, `/v1/ads/{adId}`). | `bidStrategy` | Required companion field | Notes | |---|---|---| | `LOWEST_COST_WITHOUT_CAP` (default) | — | Auto-bid; Meta optimizes to spend the budget. | | `LOWEST_COST_WITH_BID_CAP` | `bidAmount` (whole currency units) | Auto-bid with a hard ceiling. | | `COST_CAP` | `bidAmount` (whole currency units) | Target average cost per result. | | `LOWEST_COST_WITH_MIN_ROAS` | `roasAverageFloor` (decimal multiplier, e.g. `2.0` = 2.0× ROAS) | Requires a value-optimized campaign (e.g. `OUTCOME_SALES` with a connected pixel/dataset). | `bidAmount` is whole currency units of the ad account (USD: `5` = $5.00; JPY: `100` = ¥100). Internally converted to Meta's smallest-denomination integer (cents for USD). `roasAverageFloor` is a decimal multiplier; we encode it as Meta's `bid_constraints.roas_average_floor` × 10000 (so `2.0` → `20000`). Create an ad with a $5 cost cap: ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'ACCOUNT_ID', adAccountId: 'act_123', name: 'Spring sale', goal: 'conversions', budgetAmount: 50, budgetType: 'daily', headline: 'Spring sale', body: '20% off everything', callToAction: 'SHOP_NOW', linkUrl: 'https://example.com', imageUrl: 'https://cdn.example.com/banner.jpg', bidStrategy: 'COST_CAP', bidAmount: 5, promotedObject: { pixelId: '1729525464415281', customEventType: 'PURCHASE' }, }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="ACCOUNT_ID", ad_account_id="act_123", name="Spring sale", goal="conversions", budget_amount=50, budget_type="daily", headline="Spring sale", body="20% off everything", call_to_action="SHOP_NOW", link_url="https://example.com", image_url="https://cdn.example.com/banner.jpg", bid_strategy="COST_CAP", bid_amount=5, promoted_object={"pixelId": "1729525464415281", "customEventType": "PURCHASE"}, ) ``` ```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": "act_123", "name": "Spring sale", "goal": "conversions", "budgetAmount": 50, "budgetType": "daily", "headline": "Spring sale", "body": "20% off everything", "callToAction": "SHOP_NOW", "linkUrl": "https://example.com", "imageUrl": "https://cdn.example.com/banner.jpg", "bidStrategy": "COST_CAP", "bidAmount": 5, "promotedObject": { "pixelId": "1729525464415281", "customEventType": "PURCHASE" } }' ``` Switch an ad set to ROAS-floor bidding: ```typescript await zernio.adcampaigns.updateAdSet({ path: { adSetId: 'AD_SET_ID' }, body: { platform: 'facebook', bidStrategy: 'LOWEST_COST_WITH_MIN_ROAS', roasAverageFloor: 2.5, }, }); ``` ```python client.ad_campaigns.update_ad_set( ad_set_id="AD_SET_ID", platform="facebook", bid_strategy="LOWEST_COST_WITH_MIN_ROAS", roas_average_floor=2.5, ) ``` ```bash curl -X PUT "https://zernio.com/api/v1/ads/ad-sets/AD_SET_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook", "bidStrategy": "LOWEST_COST_WITH_MIN_ROAS", "roasAverageFloor": 2.5 }' ``` Campaign-level `PUT /v1/ads/campaigns/{campaignId}` accepts `bidStrategy` only — Meta's spec has no `bid_amount` or `bid_constraints` at the campaign level (those live on the ad set). Campaign-level bid edits also require the campaign to be CBO (campaign-level budget); ABO campaigns return 409 with a pointer to the ad-set endpoint. ### ROAS + revenue-per-event Every `metrics` object on `/v1/ads/tree`, `/v1/ads/campaigns`, and `/v1/ads/{adId}` now carries three Meta-specific monetary fields alongside the existing `actions` + `conversions` counts: | Field | Type | Notes | |-------|------|-------| | `actionValues` | `{[action_type]: number}` | Monetary mirror of `actions`, from Meta's `action_values[]`. Values in ad-account native currency (see the campaign's `currency` field). Populated for the same action types Meta reports values on (purchases, AddToCart with value, etc.). | | `purchaseValue` | `number` | Convenience sum of purchase-type action values, picked from `actionValues` via the same priority list as `conversions` (`offsite_conversion.fb_pixel_purchase` → `omni_purchase` → `purchase`). Same unit as `spend`. | | `roas` | `number` | Derived `purchaseValue / spend`. Recomputed from summed numerator + denominator at ad-set and campaign levels (not averaged across children) so the rollup is mathematically correct. Equivalent to Meta's `purchase_roas` under default attribution. | Example campaign rollup for a purchase campaign: ```json "metrics": { "spend": 493.39, "purchaseValue": 2456.78, "roas": 4.98, "conversions": 42, "costPerConversion": 11.75, "actions": { "offsite_conversion.fb_pixel_purchase": 42, "add_to_cart": 138, "link_click": 1205 }, "actionValues": { "offsite_conversion.fb_pixel_purchase": 2456.78, "add_to_cart": 4230.50 } } ``` For cost-per-AddToCart, cost-per-Lead, etc., read the relevant `actions[key]` divided by `spend`. Pick `offsite_conversion.fb_pixel_*` keys in `actions` / `actionValues` when available to avoid Meta's parallel pixel+omni+canonical reporting (same conversion counted under multiple keys). ### Reading the campaign tree `GET /v1/ads/tree` returns nested Campaign → Ad Set → Ad with rolled-up metrics (including `conversions` and raw `actions` counts). Each campaign node exposes: | Field | Type | Notes | |-------|------|-------| | `status` | `active \| paused \| pending_review \| …` | Delivery status derived from child ads | | `reviewStatus` | `in_review \| approved \| rejected \| with_issues \| null` | Platform-side review, **distinct from `status`** | | `platformCampaignStatus` | string | Raw Meta `Campaign.effective_status` | | `campaignIssuesInfo` | object[] \| null | Meta's raw `issues_info[]` when delivery issues exist | | `budgetLevel` | `campaign \| adset \| null` | Canonical CBO/ABO switch | | `campaignBudget` | `{ amount, type } \| null` | Populated only for CBO | | `adSetBudget` (on ad-set nodes) | `{ amount, type } \| null` | Populated only for ABO | | `isBudgetScheduleEnabled` | boolean | Mirrors Meta `Campaign.is_budget_schedule_enabled` | | `currency` | string | ISO 4217 code, budgets are in ad-account native currency, NOT normalized | Default `?source=all` (matches the Zernio UI) returns both Zernio-created and platform-discovered ads. Pass `?source=zernio` to restrict. ### Update a CBO campaign budget Use `PUT /v1/ads/campaigns/{campaignId}` when `budgetLevel === 'campaign'`. If you call this on an ABO campaign, Zernio returns `409` with `code: "BUDGET_LEVEL_MISMATCH"`, route to the ad-set endpoint instead. ```typescript await zernio.adcampaigns.updateAdCampaign({ path: { campaignId: 'CAMPAIGN_ID' }, body: { platform: 'facebook', budget: { amount: 250, type: 'daily' }, }, }); ``` ```python client.ad_campaigns.update_ad_campaign( campaign_id="CAMPAIGN_ID", platform="facebook", budget={"amount": 250, "type": "daily"}, ) ``` ```bash curl -X PUT "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook", "budget": { "amount": 250, "type": "daily" } }' ``` ### Update an ABO ad-set (budget and/or status) `PUT /v1/ads/ad-sets/{adSetId}` when `budgetLevel === 'adset'`. Supply `budget` and/or `status`, at least one is required. Returns `409` with `code: "BUDGET_LEVEL_MISMATCH"` if the parent campaign is CBO. ```typescript await zernio.adcampaigns.updateAdSet({ path: { adSetId: 'ADSET_ID' }, body: { platform: 'facebook', budget: { amount: 75, type: 'daily' }, status: 'active', }, }); ``` ```python client.ad_campaigns.update_ad_set( ad_set_id="ADSET_ID", platform="facebook", budget={"amount": 75, "type": "daily"}, status="active", ) ``` ```bash curl -X PUT "https://zernio.com/api/v1/ads/ad-sets/ADSET_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook", "budget": { "amount": 75, "type": "daily" }, "status": "active" }' ``` For status-only toggles, the symmetric `PUT /v1/ads/ad-sets/{adSetId}/status` mirrors `/v1/ads/campaigns/{campaignId}/status`. ### Pause / resume a campaign (single or bulk) Single campaign, cascades to all ad sets and ads: ```typescript await zernio.adcampaigns.updateAdCampaignStatus({ path: { campaignId: 'CAMPAIGN_ID' }, body: { status: 'paused', platform: 'facebook' }, }); ``` ```python client.ad_campaigns.update_ad_campaign_status( campaign_id="CAMPAIGN_ID", status="paused", platform="facebook", ) ``` ```bash curl -X PUT "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/status" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "paused", "platform": "facebook" }' ``` Bulk (up to 50 campaigns), each row's result is reported independently so one failure doesn't fail the whole batch: ```typescript await zernio.adcampaigns.bulkUpdateAdCampaignStatus({ body: { status: 'paused', campaigns: [ { platformCampaignId: '1202...', platform: 'facebook' }, { platformCampaignId: '1203...', platform: 'facebook' }, ], }, }); ``` ```python client.ad_campaigns.bulk_update_ad_campaign_status( status="paused", campaigns=[ {"platformCampaignId": "1202...", "platform": "facebook"}, {"platformCampaignId": "1203...", "platform": "facebook"}, ], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/campaigns/bulk-status" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "paused", "campaigns": [ { "platformCampaignId": "1202...", "platform": "facebook" }, { "platformCampaignId": "1203...", "platform": "facebook" } ] }' ``` ### Duplicate a campaign `POST /v1/ads/campaigns/{campaignId}/duplicate` wraps Meta's native `POST /{campaign-id}/copies` endpoint. Defaults to `deepCopy: true` + `statusOption: "PAUSED"` so the copy is created with the full hierarchy but doesn't start delivering until you activate it. Zernio triggers a sync discovery after the copy so the new hierarchy materializes in `/ads/tree` within seconds (set `syncAfter: false` to skip). ```typescript const result = await zernio.adcampaigns.duplicateAdCampaign({ path: { campaignId: 'CAMPAIGN_ID' }, body: { platform: 'facebook', deepCopy: true, statusOption: 'PAUSED', renameStrategy: 'DEEP_RENAME', renameSuffix: ' (copy)', }, }); ``` ```python result = client.ad_campaigns.duplicate_ad_campaign( campaign_id="CAMPAIGN_ID", platform="facebook", deep_copy=True, status_option="PAUSED", rename_strategy="DEEP_RENAME", rename_suffix=" (copy)", ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/duplicate" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook", "deepCopy": true, "statusOption": "PAUSED", "renameStrategy": "DEEP_RENAME", "renameSuffix": " (copy)" }' ``` Returns `{ copiedCampaignId, discovery, raw }`. For hierarchies with > ~2 objects Meta requires async-batch mode; the synchronous path will return the exact Meta error message so you can fall back to async in your client. ### Delete a campaign `DELETE /v1/ads/campaigns/{campaignId}` cascades on Meta's side (removes all ad sets + ads) and marks local Ad docs as `cancelled` in the same pass. The route requires `platform` in the body so we know which platform's API to call. ```typescript await zernio.adcampaigns.deleteAdCampaign({ path: { campaignId: 'CAMPAIGN_ID' }, body: { platform: 'facebook' }, }); ``` ```python client.ad_campaigns.delete_ad_campaign( campaign_id="CAMPAIGN_ID", platform="facebook", ) ``` ```bash curl -X DELETE "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook" }' ``` --- # Conversions API Send server-side conversion events to Meta import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Conversions API Send offline conversion events (deal closed, lead qualified, trial converted) back to Meta via the Graph API `events` endpoint. Zernio uses the Meta Ads account you already connected, no additional OAuth, no pixel-scoped CAPI token to paste. PII is SHA-256 hashed server-side per Meta's spec before anything leaves your server. ### Discover available pixels ```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 pixel accessible to the connected ad accounts. Use the returned `id` as `destinationId` on the send call. ### Send a conversion event ```typescript const result = await zernio.ads.sendConversions({ body: { accountId: 'ACCOUNT_ID', destinationId: '1729525464415281', // pixel ID events: [{ eventName: 'Lead', eventTime: Math.floor(Date.now() / 1000), eventId: 'order_abc_123', // dedup key, must match pixel event if dual-tracking value: 42.50, currency: 'USD', actionSource: 'crm', user: { email: 'customer@example.com', phone: '+14155551234', firstName: 'Jane', lastName: 'Doe', country: 'US', }, }], }}); ``` ```python result = client.ads.send_conversions( account_id="ACCOUNT_ID", destination_id="1729525464415281", events=[{ "eventName": "Lead", "eventTime": int(time.time()), "eventId": "order_abc_123", "value": 42.50, "currency": "USD", "actionSource": "crm", "user": { "email": "customer@example.com", "phone": "+14155551234", "firstName": "Jane", "lastName": "Doe", "country": "US", }, }], ) ``` ```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": "1729525464415281", "events": [{ "eventName": "Lead", "eventTime": 1744732800, "eventId": "order_abc_123", "value": 42.50, "currency": "USD", "actionSource": "crm", "user": { "email": "customer@example.com", "phone": "+14155551234", "firstName": "Jane", "lastName": "Doe", "country": "US" } }] }' ``` ### Match keys The more identifiers you send on `user`, the higher Meta's Event Match Quality. All are normalized and SHA-256 hashed server-side per Meta's spec (you send plaintext); `ipAddress`, `userAgent`, `fbc`, and `fbp` are sent unhashed per Meta's requirement. Supported keys: - Contact: `email`, `phone`, `firstName`, `lastName` - Location / demographic: `country`, `city`, `state`, `zip`, `dob` (YYYYMMDD), `gender` (`f` or `m`) - Identifiers: `externalId` (your stable user/device id), `ipAddress`, `userAgent` - Click IDs (under `user.clickIds`): `fbc` (from the `fbclid` URL param), `fbp` (the `_fbp` cookie) The highest-signal web keys are `fbc`, `fbp`, `externalId`, `ipAddress`, and `userAgent`, send them whenever you capture them at first touch. ### Standard event names `Purchase`, `Lead`, `CompleteRegistration`, `AddToCart`, `InitiateCheckout`, `AddPaymentInfo`, `Subscribe`, `StartTrial`, `ViewContent`, `Search`, `Contact`, `SubmitApplication`, `Schedule`. Custom event names are accepted too. ### Test mode Pass `testCode: "TEST12345"` at the request root to route events to the Test Events tab in Meta Events Manager without affecting production pixel data. ### Deduplication Pass a stable `eventId` on every event. Meta dedupes against your pixel within a 48-hour window when `eventId` matches. Missing or inconsistent `eventId` between pixel and CAPI double-counts conversions, the #1 cause of inflated reports. ### Batching Up to 1,000 events per request (Zernio chunks larger batches automatically). Meta rejects the entire batch if any event is malformed, the response includes `failures[]` with per-event error detail and a `traceId` you can look up in Meta support. ### Event Match Quality Read Event Match Quality (EMQ) and pixel-to-CAPI coverage back from Meta without opening Events Manager. Pass the pixel/dataset `destinationId` and you get, per event, the composite EMQ score (0-10), per-match-key coverage, and the event coverage rate (how well your CAPI events dedupe against the pixel). ```typescript const { data } = await zernio.ads.getConversionsQuality({ query: { accountId: 'ACCOUNT_ID', destinationId: '1729525464415281' }, }); ``` ```python data = client.ads.get_conversions_quality( account_id="ACCOUNT_ID", destination_id="1729525464415281", ) ``` ```bash curl "https://zernio.com/api/v1/ads/conversions/quality?accountId=ACCOUNT_ID&destinationId=1729525464415281" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json { "platform": "metaads", "rows": [ { "eventName": "Purchase", "compositeScore": 6.2, "matchKeys": [{ "identifier": "email", "coveragePercentage": 80 }], "eventCoveragePercentage": 75 } ] } ``` Web events only (a Meta limitation, app and offline EMQ aren't exposed by the API). Uses the connected Meta Ads account, no extra permission. --- # Catalog Ads Advantage+ catalog ads — dynamic product and vehicle ads from a Meta product catalog import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Advantage+ catalog ads Catalog ads (Meta's dynamic product ads / Advantage+ catalog ads) promote items from a Meta **product catalog** — e-commerce products, vehicle inventory, hotels — with creatives Meta renders per item. One ad, N items: Meta picks which product or vehicle each person sees. Zernio supports them end-to-end with `goal: "catalog_sales"` on `POST /v1/ads/create`, plus two discovery endpoints to find the customer's catalogs and product sets. Everything works with the permissions accounts already granted on connect — no reconnect needed. **Catalog contents are managed in Meta Commerce Manager** (or a feed provider), not through this API. Zernio reads the catalogs and runs ads on them. The ad account also needs a Meta Pixel — a Meta requirement for catalog campaigns (see the table below). ## 1. Find the catalog and product set Catalogs hang off the ad account's business. List them, then list the chosen catalog's product sets — the product set is what the ad actually promotes. ```typescript const { catalogs } = await zernio.ads.listAdCatalogs({ accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', }); // [{ id: '1003405825408877', name: 'Vehicle Inventory', vertical: 'vehicles', productCount: 132 }] const { productSets } = await zernio.ads.listAdCatalogProductSets({ catalogId: catalogs[0].id, accountId: 'acc_metaads_123', }); // [{ id: '1003260032095191', name: 'All vehicles', productCount: 132 }] ``` ```python catalogs = client.ads.list_ad_catalogs( account_id="acc_metaads_123", ad_account_id="act_1234567890", ) product_sets = client.ads.list_ad_catalog_product_sets( catalog_id=catalogs["catalogs"][0]["id"], account_id="acc_metaads_123", ) ``` ```bash curl "https://zernio.com/api/v1/ads/catalogs?accountId=acc_metaads_123&adAccountId=act_1234567890" \ -H "Authorization: Bearer $ZERNIO_API_KEY" curl "https://zernio.com/api/v1/ads/catalogs/1003405825408877/product-sets?accountId=acc_metaads_123" \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` ## 2. Create the catalog campaign `goal: "catalog_sales"` builds the whole chain: a Sales-objective campaign, an ad set bound to the product set, and a **catalog template creative**. There is no `imageUrl` or `video` — Meta renders the visuals from the catalog items. The copy fields become the template, and they accept Meta's catalog template tags (`{{product.name}}`, `{{product.price}}`, or for vehicle catalogs `{{vehicle.make}}`, `{{vehicle.model}}`, `{{vehicle.year}}`, `{{vehicle.price}}`), so each rendered item carries its own data. | Required `promotedObject` field | Why | |---|---| | `productSetId` | The items to promote (and the template creative's source). | | `pixelId` | Meta requires the pixel on catalog ad sets — its "Promoted Object is Required" error actually means the pixel is missing. | | `customEventType` | The conversion event to optimize toward, e.g. `PURCHASE`, `LEAD`, `VIEW_CONTENT`. | ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Vehicle inventory - catalog', goal: 'catalog_sales', budgetAmount: 30, budgetType: 'daily', headline: '{{vehicle.year}} {{vehicle.make}} {{vehicle.model}}', body: 'Find your next car', description: '{{vehicle.price}}', callToAction: 'LEARN_MORE', linkUrl: 'https://example.com/inventory', countries: ['CL'], promotedObject: { productSetId: '1003260032095191', pixelId: '1729525464415281', customEventType: 'PURCHASE', }, }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", name="Vehicle inventory - catalog", goal="catalog_sales", budget_amount=30, budget_type="daily", headline="{{vehicle.year}} {{vehicle.make}} {{vehicle.model}}", body="Find your next car", description="{{vehicle.price}}", call_to_action="LEARN_MORE", link_url="https://example.com/inventory", countries=["CL"], promoted_object={ "productSetId": "1003260032095191", "pixelId": "1729525464415281", "customEventType": "PURCHASE", }, ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "acc_metaads_123", "adAccountId": "act_1234567890", "name": "Vehicle inventory - catalog", "goal": "catalog_sales", "budgetAmount": 30, "budgetType": "daily", "headline": "{{vehicle.year}} {{vehicle.make}} {{vehicle.model}}", "body": "Find your next car", "description": "{{vehicle.price}}", "callToAction": "LEARN_MORE", "linkUrl": "https://example.com/inventory", "countries": ["CL"], "promotedObject": { "productSetId": "1003260032095191", "pixelId": "1729525464415281", "customEventType": "PURCHASE" } }' ``` ## Behavior and limits - **Targeting** works like any other campaign (countries, age, gender, placements, saved audiences). Broad targeting is fine — Meta matches items to people. Retargeting off pixel events can be layered with `optimizationGoal: "OFFSITE_CONVERSIONS"`. - **Single shape only.** `catalog_sales` can't be combined with `creatives[]`, `adSetId` (attach), `dynamicCreative`, or `placementAssets` — the catalog template is already the multi-item mechanism. - **Instagram placement** is handled for you: catalog creatives need an explicit Instagram identity, and Zernio falls back to the Page-backed Instagram account automatically when the connected Page has no linked IG. - The default ad-set optimization is `LINK_CLICKS`; override with `optimizationGoal` (e.g. `OFFSITE_CONVERSIONS`). - The legacy `PRODUCT_CATALOG_SALES` objective is retired by Meta on modern ad accounts; Zernio uses the current Sales-objective shape. --- # Conversion Campaigns Conversion campaigns and promoted objects import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Conversion campaigns and promoted objects Optimization goals that point at a specific event/page/app need a `promotedObject` on the ad set. Without it Meta rejects the ad-set create with `error_subcode: 1815430` (*"Please select a promoted object for your ad set."*). Zernio enforces this upfront so you get a clean `400 invalid_request_error` with `param: "promotedObject"` instead of a passthrough Meta error. | `goal` | Meta objective + optimization | Required `promotedObject` fields | |--------|-------------------|----------------------------------| | `conversions` | Sales → `OFFSITE_CONVERSIONS` | `pixelId` + `customEventType` (a commerce event, e.g. `PURCHASE`, `ADD_TO_CART`, `START_TRIAL`) | | `lead_conversion` | Leads → `OFFSITE_CONVERSIONS` | `pixelId` + `customEventType` (a leads event, e.g. `LEAD`, `SUBMIT_APPLICATION`, `SCHEDULE`, `CONTACT`) | | `app_promotion` | App promotion → `APP_INSTALLS` | `applicationId` + `objectStoreUrl` | | `lead_generation` | Leads → `LEAD_GENERATION` (instant form) | `pageId` (auto-filled from the connected Page when omitted) | | `catalog_sales` | Sales → catalog template creative | `productSetId` + `pixelId` + `customEventType` — see [Catalog Ads](/platforms/meta-ads/catalog-ads) | `customEventType` accepts Meta's standard event names: `PURCHASE`, `LEAD`, `COMPLETE_REGISTRATION`, `ADD_TO_CART`, `INITIATE_CHECKOUT`, `ADD_PAYMENT_INFO`, `SUBSCRIBE`, `START_TRIAL`, `VIEW_CONTENT`, `SEARCH`, `CONTACT`, `SUBMIT_APPLICATION`, `SCHEDULE`. Custom-conversion-driven optimization is supported via `customConversionId` instead of (or alongside) `customEventType`. **`conversions` vs `lead_conversion`.** Meta gates which conversion events are valid by campaign objective. `conversions` runs the **Sales** objective (commerce events like `PURCHASE`); a leads event such as `LEAD` is rejected there. For **website pixel lead** optimization use `lead_conversion` (the **Leads** objective with `OFFSITE_CONVERSIONS`). `lead_generation` is also the Leads objective but optimizes for an **instant form** (`leadGenFormId`) rather than a website pixel event. **Override the ad-set optimization goal.** By default the ad-set `optimization_goal` is derived from `goal` (e.g. `traffic` → `LINK_CLICKS`). Pass `optimizationGoal` to set it explicitly, e.g. `"LANDING_PAGE_VIEWS"`, `"REACH"`, `"IMPRESSIONS"`, `"OFFSITE_CONVERSIONS"`, `"THRUPLAY"`. It's forwarded verbatim and Meta validates it against the campaign objective (incompatible combinations are rejected). ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring sale - Purchase optimization', goal: 'conversions', budgetAmount: 75, budgetType: 'daily', headline: 'Spring Sale - 30% off', body: 'Limited time. Upgrade today.', imageUrl: 'https://cdn.example.com/spring.jpg', callToAction: 'SHOP_NOW', linkUrl: 'https://example.com/spring', countries: ['US'], promotedObject: { pixelId: '1729525464415281', customEventType: 'PURCHASE', }, }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", name="Spring sale - Purchase optimization", goal="conversions", budget_amount=75, budget_type="daily", headline="Spring Sale - 30% off", body="Limited time. Upgrade today.", image_url="https://cdn.example.com/spring.jpg", call_to_action="SHOP_NOW", link_url="https://example.com/spring", countries=["US"], promoted_object={ "pixelId": "1729525464415281", "customEventType": "PURCHASE", }, ) ``` ```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_metaads_123", "adAccountId": "act_1234567890", "name": "Spring sale - Purchase optimization", "goal": "conversions", "budgetAmount": 75, "budgetType": "daily", "headline": "Spring Sale - 30% off", "body": "Limited time. Upgrade today.", "imageUrl": "https://cdn.example.com/spring.jpg", "callToAction": "SHOP_NOW", "linkUrl": "https://example.com/spring", "countries": ["US"], "promotedObject": { "pixelId": "1729525464415281", "customEventType": "PURCHASE" } }' ``` Need a pixel ID? Use [`zernio.trackingtags.listTrackingTags()`](/tracking-tags/list-tracking-tags) to list the pixels a connected ad account can see (or [`zernio.trackingtags.createTrackingTag()`](/tracking-tags/create-tracking-tag) to make one) — see [Meta Pixels](/platforms/meta-ads/pixels#meta-pixels) below. [`zernio.ads.listConversionDestinations()`](/platforms/meta-ads/capi#conversions-api) returns the same set, in case you're already wiring that. Other Meta-specific knobs on `promotedObject`: | Field | When you'd use it | |-------|-------------------| | `customConversionId` | Optimising against a Custom Conversion (instead of a standard event). Pair with `pixelId`. | | `productCatalogId` + `productSetId` | Catalog Ads / Advantage+ Shopping campaigns. | | `applicationId` + `objectStoreUrl` | App-install / app-engagement campaigns (`goal: "app_promotion"`). | --- # Creative Testing Run multi-creative campaigns to test variations import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Multi-creative campaigns (creative testing) For creative A/B testing, don't spin up one campaign per creative, that fragments the budget across parallel ad sets and none of them exit Meta's learning phase (~50 conversions/week required). Instead, pass `creatives[]` on `POST /v1/ads/create` to get 1 campaign → 1 ad set → N ads sharing the same budget, targeting, and schedule. Meta's delivery algorithm allocates budget across the creatives inside the single ad set. ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring Launch', goal: 'traffic', budgetAmount: 100, budgetType: 'daily', countries: ['AR'], ageMin: 25, ageMax: 45, creatives: [ { headline: 'Spring - 30% off', body: 'Limited time.', imageUrl: 'https://cdn.example.com/a.jpg', linkUrl: 'https://example.com/a', callToAction: 'SHOP_NOW' }, { headline: 'Just for you', body: 'Curated picks.', imageUrl: 'https://cdn.example.com/b.jpg', linkUrl: 'https://example.com/b', callToAction: 'SHOP_NOW' }, { headline: 'Free shipping', body: 'This week only.', imageUrl: 'https://cdn.example.com/c.jpg', linkUrl: 'https://example.com/c', callToAction: 'SHOP_NOW' }, ], }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", name="Spring Launch", goal="traffic", budget_amount=100, budget_type="daily", countries=["AR"], age_min=25, age_max=45, creatives=[ {"headline": "Spring - 30% off", "body": "Limited time.", "imageUrl": "https://cdn.example.com/a.jpg", "linkUrl": "https://example.com/a", "callToAction": "SHOP_NOW"}, {"headline": "Just for you", "body": "Curated picks.", "imageUrl": "https://cdn.example.com/b.jpg", "linkUrl": "https://example.com/b", "callToAction": "SHOP_NOW"}, {"headline": "Free shipping", "body": "This week only.", "imageUrl": "https://cdn.example.com/c.jpg", "linkUrl": "https://example.com/c", "callToAction": "SHOP_NOW"}, ], ) ``` ```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_metaads_123", "adAccountId": "act_1234567890", "name": "Spring Launch", "goal": "traffic", "budgetAmount": 100, "budgetType": "daily", "countries": ["AR"], "ageMin": 25, "ageMax": 45, "creatives": [ { "headline": "Spring - 30% off", "body": "Limited time.", "imageUrl": "https://cdn.example.com/a.jpg", "linkUrl": "https://example.com/a", "callToAction": "SHOP_NOW" }, { "headline": "Just for you", "body": "Curated picks.", "imageUrl": "https://cdn.example.com/b.jpg", "linkUrl": "https://example.com/b", "callToAction": "SHOP_NOW" }, { "headline": "Free shipping", "body": "This week only.", "imageUrl": "https://cdn.example.com/c.jpg", "linkUrl": "https://example.com/c", "callToAction": "SHOP_NOW" } ] }' ``` Returns `{ ads: [...], platformCampaignId, platformAdSetId, message }`, N Ad documents sharing the same `platformCampaignId` + `platformAdSetId`. Each ad's name is `" #N"` (e.g. "Spring Launch #1", "Spring Launch #2"). To add more creatives later without spinning up a new campaign, use the attach shape below. Meta-only today. Non-Meta platforms return 400. --- # Creatives Attach creatives, video creatives, and placement asset customization import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Attach a creative to an existing ad set To iterate on creatives after a campaign is already running, pass `adSetId` on `POST /v1/ads/create` with a single creative. One new ad gets attached to the existing ad set, budget, targeting, schedule, and goal are inherited from the ad set on Meta's side. No new campaign is created. ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', adSetId: '120250000000000000', name: 'Spring Launch #4', headline: 'One more angle', body: 'Social proof variant.', imageUrl: 'https://cdn.example.com/d.jpg', linkUrl: 'https://example.com/d', callToAction: 'SHOP_NOW', }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", ad_set_id="120250000000000000", name="Spring Launch #4", headline="One more angle", body="Social proof variant.", image_url="https://cdn.example.com/d.jpg", link_url="https://example.com/d", call_to_action="SHOP_NOW", ) ``` ```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_metaads_123", "adAccountId": "act_1234567890", "adSetId": "120250000000000000", "name": "Spring Launch #4", "headline": "One more angle", "body": "Social proof variant.", "imageUrl": "https://cdn.example.com/d.jpg", "linkUrl": "https://example.com/d", "callToAction": "SHOP_NOW" }' ``` Returns `{ ad, message }`. Useful for weekly creative drops onto a stable ad set that Meta has already optimised on. ## Video creatives All three shapes (standalone, multi-creative, attach) accept video. Replace `imageUrl` with a `video` object carrying the video URL and a still-image thumbnail: ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring launch video', goal: 'video_views', budgetAmount: 75, budgetType: 'daily', headline: 'Spring drop is live', body: 'See it in action.', video: { url: 'https://cdn.example.com/spring.mp4', thumbnailUrl: 'https://cdn.example.com/spring-poster.jpg', }, callToAction: 'SHOP_NOW', linkUrl: 'https://example.com/spring', }, }); ``` ```python result = client.ads.create_standalone_ad( account_id="acc_metaads_123", ad_account_id="act_1234567890", name="Spring launch video", goal="video_views", budget_amount=75, budget_type="daily", headline="Spring drop is live", body="See it in action.", video={ "url": "https://cdn.example.com/spring.mp4", "thumbnailUrl": "https://cdn.example.com/spring-poster.jpg", }, call_to_action="SHOP_NOW", link_url="https://example.com/spring", ) ``` ```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_metaads_123", "adAccountId": "act_1234567890", "name": "Spring launch video", "goal": "video_views", "budgetAmount": 75, "budgetType": "daily", "headline": "Spring drop is live", "body": "See it in action.", "video": { "url": "https://cdn.example.com/spring.mp4", "thumbnailUrl": "https://cdn.example.com/spring-poster.jpg" }, "callToAction": "SHOP_NOW", "linkUrl": "https://example.com/spring" }' ``` For multi-creative, put `video: { url, thumbnailUrl }` on each entry of `creatives[]` instead of `imageUrl`. `video` and `imageUrl` are mutually exclusive per creative, supply exactly one. **Thumbnail is optional.** When you omit `video.thumbnailUrl`, the poster is auto-generated from Meta's own preferred video thumbnail (the same candidates Ads Manager shows), so `"video": { "url": "..." }` is a complete video creative. Supply your own public image URL (1200x628 or 1080x1080 recommended) to control the exact still frame — a supplied thumbnail always wins. **Sync upload, long-running.** Zernio uploads the video to Meta via chunked transfer and blocks until Meta finishes transcoding (`status.video_status === "ready"`). Longer videos can take several minutes. The endpoint is configured with `maxDuration = 800s` on our side; set your HTTP client timeout to at least 15 minutes so it doesn't bail before Meta finishes. If transcoding hasn't completed within 10 minutes, the request fails with a `platform_error`. ## Placement asset customization This is Ads Manager's "use a different creative per placement" on one ad: a 9:16 asset on Stories and Reels, a 1:1 or 4:5 on Feed, all served by a single ad. Pass a `placementAssets` object to `/v1/ads/create`. Each rule pins one asset to one or more placements, and a default asset covers every placement no rule matches. This is distinct from [multi-creative campaigns](/platforms/meta-ads/creative-testing#multi-creative-campaigns-creative-testing) (N separate ads Meta A/B-tests) and from `dynamicCreative` (an asset *pool* Meta auto-optimizes). `placementAssets` is deterministic: you decide exactly which asset shows where. **Meta only** (`facebook` / `instagram`). The shared copy (`headline`, `body`, `linkUrl`, `callToAction`) comes from the top-level fields, only the *image or video* varies per placement. ### Image per placement ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring launch (placement-tailored)', goal: 'traffic', budgetAmount: 50, budgetType: 'daily', headline: 'Spring drop is live', body: 'See it in action.', callToAction: 'SHOP_NOW', linkUrl: 'https://example.com/spring', placementAssets: { // Catch-all: shown on any placement no rule below matches. defaultImageUrl: 'https://cdn.example.com/1x1.jpg', rules: [ { imageUrl: 'https://cdn.example.com/9x16.jpg', placements: { publisherPlatforms: ['instagram', 'facebook'], instagramPositions: ['story', 'reels'], facebookPositions: ['story', 'facebook_reels'], }, }, { imageUrl: 'https://cdn.example.com/4x5.jpg', placements: { instagramPositions: ['stream'], facebookPositions: ['feed'], }, }, ], }, }, }); ``` ### Video per placement Swap `imageUrl` for `videoUrl` (and `defaultImageUrl` for `defaultVideoUrl`). Poster thumbnails are optional here. Meta auto-generates one if you omit `thumbnailUrl` / `defaultThumbnailUrl`, though supplying your own gives you control over the still frame. ```typescript const result = await zernio.ads.createStandaloneAd({ body: { accountId: 'acc_metaads_123', adAccountId: 'act_1234567890', name: 'Spring launch video (placement-tailored)', goal: 'video_views', budgetAmount: 75, budgetType: 'daily', headline: 'Spring drop is live', body: 'See it in action.', callToAction: 'SHOP_NOW', linkUrl: 'https://example.com/spring', placementAssets: { defaultVideoUrl: 'https://cdn.example.com/1x1.mp4', defaultThumbnailUrl: 'https://cdn.example.com/1x1-poster.jpg', // optional rules: [ { videoUrl: 'https://cdn.example.com/9x16.mp4', thumbnailUrl: 'https://cdn.example.com/9x16-poster.jpg', // optional placements: { publisherPlatforms: ['instagram', 'facebook'], instagramPositions: ['story', 'reels'], facebookPositions: ['story', 'facebook_reels'], }, }, { videoUrl: 'https://cdn.example.com/4x5.mp4', placements: { instagramPositions: ['stream'], facebookPositions: ['feed'], }, }, ], }, }, }); ``` ### Rules | Field | Type | Notes | |-------|------|-------| | `defaultImageUrl` / `defaultVideoUrl` | string | Required. The catch-all asset for placements no rule matches. Set exactly one (image *or* video, see below). | | `defaultThumbnailUrl` | string | Optional. Poster for `defaultVideoUrl`; Meta auto-generates if omitted. | | `rules[]` | object[] | 1-10 rules. Each pins one asset to one or more placements. | | `rules[].imageUrl` / `rules[].videoUrl` | string | The asset for this rule. Must match the block's media mode. | | `rules[].thumbnailUrl` | string | Optional poster for `videoUrl`. | | `rules[].placements` | object | At least one placement field required (`publisherPlatforms` and/or a `*Positions` field). Same vocabulary as the [Targeting](/platforms/meta-ads/targeting#targeting) `placements` object. | **All-image or all-video, never mixed.** A `placementAssets` block is one ad format. Use `imageUrl` + `defaultImageUrl` throughout, *or* `videoUrl` + `defaultVideoUrl` throughout. Mixing the two returns a `400`. `placementAssets` builds a single placement-customized ad, so it's mutually exclusive with `creatives[]`, `adSetId`, and `dynamicCreative`. Meta enforces placement co-selection rules (e.g. `profile_feed` requires `feed`) and we surface the resulting error verbatim. **Works on instant-form lead ads too.** `placementAssets` is supported on `goal: "lead_generation"` (with a `leadGenFormId`) — the instant form is attached for you (the ad set is configured with the on-ad destination automatically). The auto-optimizing pool `dynamicCreative` is the exception: it needs a dedicated Dynamic Creative ad set, so `dynamicCreative` + a lead form returns a `422` (use `placementAssets`, a single creative, or run `dynamicCreative` on `traffic`/`conversions`). --- # Click-to-WhatsApp Ads Create ads that open a WhatsApp conversation import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Click-to-WhatsApp ads Create ads that, when tapped, open a WhatsApp conversation with your business instead of sending the user to a website. Zernio drives the full hierarchy (campaign → ad set → creative → ad) in a single call. The CTA is locked to `WHATSAPP_MESSAGE` and the destination is hard-coded to `https://api.whatsapp.com/send`, Meta resolves the actual WhatsApp number from the Page-to-WhatsApp pairing configured in your Page settings. Before calling, the Facebook Page must already be paired with a verified WhatsApp Business number (Meta Business Manager → WhatsApp Accounts → connect a Page). When the Page isn't paired, Meta rejects the ad set with subcode `2446886` and Zernio surfaces a clean `platform_error` envelope. ```typescript const result = await zernio.ads.createCtwaAd({ body: { accountId: 'FB_OR_IG_ACCOUNT_ID', adAccountId: 'act_123456789', name: 'Summer promo, WhatsApp', headline: 'Chat with us on WhatsApp', body: 'Get a personal quote in 2 minutes.', imageUrl: 'https://cdn.example.com/promo.jpg', budgetAmount: 20, budgetType: 'daily', currency: 'USD', countries: ['US', 'ES'], }}); ``` ```python result = client.ads.create_ctwa_ad( account_id="FB_OR_IG_ACCOUNT_ID", ad_account_id="act_123456789", name="Summer promo, WhatsApp", headline="Chat with us on WhatsApp", body="Get a personal quote in 2 minutes.", image_url="https://cdn.example.com/promo.jpg", budget_amount=20, budget_type="daily", currency="USD", countries=["US", "ES"], ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/ctwa" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "FB_OR_IG_ACCOUNT_ID", "adAccountId": "act_123456789", "name": "Summer promo, WhatsApp", "headline": "Chat with us on WhatsApp", "body": "Get a personal quote in 2 minutes.", "imageUrl": "https://cdn.example.com/promo.jpg", "budgetAmount": 20, "budgetType": "daily", "currency": "USD", "countries": ["US", "ES"] }' ``` To close the attribution loop on conversions that happen *inside* the WhatsApp thread, see [Conversions API for Business Messaging](/platforms/whatsapp/ctwa#conversions-api-for-business-messaging) on the WhatsApp page. ### Local CTWA targeting (city radius, regions, ZIPs) CTWA accepts the full geo-targeting shape, not just countries. For local businesses (e.g. a 25km radius around Milan), pass `cities`, `regions`, `zips`, `metros`, or `customLocations` alongside or instead of `countries`. Same wire shape as [`/v1/ads/create`](/platforms/meta-ads/targeting#targeting), `key`s come from [`/v1/ads/targeting/search`](/platforms/meta-ads/targeting#looking-up-city--region-keys). ```typescript const result = await zernio.ads.createCtwaAd({ body: { accountId: 'FB_OR_IG_ACCOUNT_ID', adAccountId: 'act_123456789', name: 'Milano local CTWA', headline: 'Chat with us on WhatsApp', body: 'Get a personal quote in 2 minutes.', imageUrl: 'https://cdn.example.com/promo.jpg', budgetAmount: 20, budgetType: 'daily', currency: 'EUR', countries: ['IT'], cities: [{ key: '2643743', radius: 25, distance_unit: 'kilometer' }], dsaBeneficiary: 'Acme Srl', dsaPayor: 'Acme Srl', }}); ``` ```python result = client.ads.create_ctwa_ad( account_id="FB_OR_IG_ACCOUNT_ID", ad_account_id="act_123456789", name="Milano local CTWA", headline="Chat with us on WhatsApp", body="Get a personal quote in 2 minutes.", image_url="https://cdn.example.com/promo.jpg", budget_amount=20, budget_type="daily", currency="EUR", countries=["IT"], cities=[{"key": "2643743", "radius": 25, "distance_unit": "kilometer"}], dsa_beneficiary="Acme Srl", dsa_payor="Acme Srl", ) ``` ```bash curl -X POST "https://zernio.com/api/v1/ads/ctwa" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "FB_OR_IG_ACCOUNT_ID", "adAccountId": "act_123456789", "name": "Milano local CTWA", "headline": "Chat with us on WhatsApp", "body": "Get a personal quote in 2 minutes.", "imageUrl": "https://cdn.example.com/promo.jpg", "budgetAmount": 20, "budgetType": "daily", "currency": "EUR", "countries": ["IT"], "cities": [{ "key": "2643743", "radius": 25, "distance_unit": "kilometer" }], "dsaBeneficiary": "Acme Srl", "dsaPayor": "Acme Srl" }' ``` The legacy `countries: ['US']` default only kicks in when no geo at all is supplied. If you pass `cities` (or any other sub-country geo) without `countries`, Zernio targets exactly that geo, not US. --- # Meta Ads Create and boost Facebook + Instagram ads via Zernio API import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { Cards, Card } from 'fumadocs-ui/components/card'; **Included with the [Usage plan](/pricing).** No Meta App review needed. Zernio is an approved Meta Marketing Partner, so you skip the App Review + ads_management permissions entirely. **`accountId` accepts either shape.** You can pass the posting account ID (`facebook` / `instagram`) or the ads credential account ID (`metaads`), Zernio resolves the sibling internally. For Instagram ads, Zernio auto-resolves the linked Facebook Page and the Instagram Business Account ID (no extra field needed). If the Instagram account has no linked Facebook account, the API returns `code: "linked_account_required"` so you can surface a reconnect flow. ## What's Supported | Feature | Status | |---------|--------| | Standalone campaigns (FB + IG) | Yes | | Multi-creative campaigns (1 campaign → N ads) | Yes | | Attach creative to existing ad set | Yes | | Video creatives (all 3 shapes) | Yes | | Placement asset customization (different image *or* video per placement) ([details](/platforms/meta-ads/creatives#placement-asset-customization)) | Yes | | Boost organic posts | Yes | | CBO vs ABO budget routing + updates | Yes | | Campaign duplication (deep copy) | Yes | | Campaign deletion (cascades to ad sets + ads) | Yes | | Bulk pause / resume campaigns | Yes | | Ad-set-scoped pause / resume | Yes | | Custom Audiences (customer list, website, lookalike) | Yes | | Detailed targeting (interests, age, gender, location) | Yes | | Campaign > Ad Set > Ad hierarchy | Yes | | Real-time analytics (spend, CPC, CPM) | Yes | | ROAS + action_values (revenue per action_type) | Yes | | Pixel management — create, install code, rename, share, stats ([details](/platforms/meta-ads/pixels#meta-pixels)) | Yes | | Conversions API (offline events, hashed PII, dedup) | Yes | | Event Match Quality read-back ([details](/platforms/meta-ads/capi#event-match-quality)) | Yes | | Ad URL tracking tags — read + update ([details](/platforms/meta-ads/tracking-tags#ad-url-tracking-tags)) | Yes | | Click-to-WhatsApp ads (CTWA) | Yes | | Ad comments (read + moderate dark posts) | Yes | | Lead Gen Forms (create forms, attach to standalone + boosted ads, leads via webhook) ([details](/platforms/meta-ads/lead-forms#lead-gen-forms)) | Yes | | Ad Library API | Roadmap | | Advantage+ campaigns | Roadmap | ## In This Section --- # Lead Gen Forms Create and manage Meta lead generation forms import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Lead Gen Forms Create native Meta **Lead Gen (Instant) Forms**, attach them to ads, and receive submitted leads in real time. The full flow: 1. **Create a form** on a connected Facebook account with `POST /v1/ads/lead-forms`. Forms are Page-scoped (Instagram lead ads use the linked Page's form under the hood). 2. **Attach it to an ad** by setting `goal: "lead_generation"` and `leadGenFormId` on [`POST /v1/ads/create`](/ads/create-standalone-ad) (standalone) or [`POST /v1/ads/boost`](/ads/boost-post) (boost an existing post). The ad set's lead optimization and promoted Page are derived automatically, no destination URL is needed. 3. **Receive leads** in real time via the [`lead.received`](/webhooks#leadreceived) webhook, or pull them with `GET /v1/ads/lead-forms/{formId}/leads`. **One-time setup:** your Facebook Page must accept Facebook's Lead Generation Terms of Service before lead ads can run. If ad creation returns Meta error subcode `1815089` ("Terms of Service Not Accepted"), accept them once at `https://www.facebook.com/ads/leadgen/tos/?page_id=YOUR_PAGE_ID`. Creating forms and reading leads works without it, only running ads requires it. ### Create a form, then a lead ad Prefilled question types (`EMAIL`, `PHONE`, `FULL_NAME`, `FIRST_NAME`, `LAST_NAME`, …) auto-generate their label and key, send only `{ "type": "..." }`. `CUSTOM` questions require `key`, `label`, and (for choice questions) `options`. ```typescript // 1. Create the form const { form } = await zernio.ads.createLeadForm({ body: { accountId: 'FACEBOOK_ACCOUNT_ID', name: 'Spring promo', questions: [ { type: 'EMAIL' }, { type: 'FULL_NAME' }, { type: 'CUSTOM', key: 'budget', label: 'Monthly budget?', options: [ { key: 'low', value: 'Under $1k' }, { key: 'high', value: '$1k+' }, ] }, ], privacyPolicyUrl: 'https://example.com/privacy', } }); // 2. Create an ad that opens the form (no linkUrl needed) const { ad } = await zernio.ads.createStandaloneAd({ body: { accountId: 'FACEBOOK_ACCOUNT_ID', adAccountId: 'act_123', name: 'Spring promo lead ad', goal: 'lead_generation', leadGenFormId: form.id, budgetAmount: 10, budgetType: 'daily', headline: 'Get a quote', body: 'Tell us about your project', callToAction: 'SIGN_UP', imageUrl: 'https://example.com/creative.jpg', } }); ``` ```python # 1. Create the form form = client.ads.create_lead_form( account_id="FACEBOOK_ACCOUNT_ID", name="Spring promo", questions=[ {"type": "EMAIL"}, {"type": "FULL_NAME"}, {"type": "CUSTOM", "key": "budget", "label": "Monthly budget?", "options": [{"key": "low", "value": "Under $1k"}, {"key": "high", "value": "$1k+"}]}, ], privacy_policy_url="https://example.com/privacy", )["form"] # 2. Create an ad that opens the form (no link_url needed) ad = client.ads.create_standalone_ad( account_id="FACEBOOK_ACCOUNT_ID", ad_account_id="act_123", name="Spring promo lead ad", goal="lead_generation", lead_gen_form_id=form["id"], budget_amount=10, budget_type="daily", headline="Get a quote", body="Tell us about your project", call_to_action="SIGN_UP", image_url="https://example.com/creative.jpg", ) ``` ```bash # 1. Create the form curl -X POST "https://zernio.com/api/v1/ads/lead-forms" \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "FACEBOOK_ACCOUNT_ID", "name": "Spring promo", "questions": [{"type":"EMAIL"},{"type":"FULL_NAME"}], "privacyPolicyUrl": "https://example.com/privacy" }' # 2. Create an ad that opens the form (use the returned form id as leadGenFormId) curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "FACEBOOK_ACCOUNT_ID", "adAccountId": "act_123", "name": "Spring promo lead ad", "goal": "lead_generation", "leadGenFormId": "FORM_ID", "budgetAmount": 10, "budgetType": "daily", "headline": "Get a quote", "body": "Tell us about your project", "callToAction": "SIGN_UP", "imageUrl": "https://example.com/creative.jpg" }' ``` ### Receiving leads The best way to capture leads is the [`lead.received`](/webhooks#leadreceived) webhook, fired the moment a lead is submitted. The payload carries `lead.fields` (the question-key to answer map) plus `formId` / `adId` provenance. You can also poll for them: ```typescript const { leads } = await zernio.ads.listFormLeads({ path: { formId: 'FORM_ID' }, query: { accountId: 'FACEBOOK_ACCOUNT_ID', limit: 50 }, }); for (const lead of leads) console.log(lead.fields); ``` ```python leads = client.ads.list_form_leads( form_id="FORM_ID", account_id="FACEBOOK_ACCOUNT_ID", limit=50, )["leads"] for lead in leads: print(lead["fields"]) ``` ```bash curl "https://zernio.com/api/v1/ads/lead-forms/FORM_ID/leads?accountId=FACEBOOK_ACCOUNT_ID&limit=50" \ -H "Authorization: Bearer $ZERNIO_API_KEY" ``` To attach a form when **boosting an existing post**, pass the same `leadGenFormId` (and `goal: "lead_generation"`) to [`POST /v1/ads/boost`](/ads/boost-post). To retire a form, `DELETE /v1/ads/lead-forms/{formId}` (Meta archives it, there is no hard delete). --- # Meta Pixels Create and manage Meta Pixels import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Meta Pixels A Meta Pixel is the measurement primitive you install on a website, send events to, and target ads against. Zernio exposes it under the platform-neutral **tracking tags** API — on Meta, `kind` is `pixel`. Uses the Meta Ads account you already connected; no extra OAuth, no `business_management` permission. The `accountId` is the Meta **ads** SocialAccount (the one the Ads add-on connect flow creates), not a Facebook/Instagram posting account; get your `act_...` ad account IDs from [`zernio.ads.listAdAccounts()`](/ads/list-ad-accounts). ### Create a pixel ```typescript const { data } = await zernio.trackingtags.createTrackingTag({ path: { accountId: 'ACCOUNT_ID' }, body: { adAccountId: 'act_1234567890', name: 'My Website Pixel' }, }); console.log(data.tag.id); // new pixel ID — use it in promotedObject.pixelId, audiences, CAPI console.log(data.tag.code); // the base-code snippet to install on the site ``` ```python result = client.tracking_tags.create_tracking_tag( account_id="ACCOUNT_ID", ad_account_id="act_1234567890", name="My Website Pixel", ) print(result.tag.id) # new pixel ID print(result.tag.code) # the install snippet ``` ```bash curl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"adAccountId":"act_1234567890","name":"My Website Pixel"}' ``` The response is the new tag, including `code` — the base-code snippet to drop on the site. Creating a pixel doesn't install it: install `code`, or skip the snippet entirely and send events server-side via the [Conversions API](/platforms/meta-ads/capi#conversions-api) (a pixel works as a CAPI destination immediately). `installed` is derived from `lastFiredTime` (`null` = it has never fired). ### List, fetch, and rename pixels ```typescript // Every pixel the connected ad account can see (pass query.adAccountId to scope to one) const { data: list } = await zernio.trackingtags.listTrackingTags({ path: { accountId: 'ACCOUNT_ID' }, }); // One pixel — includes its install code, lastFiredTime, ownerBusinessId const { data: one } = await zernio.trackingtags.getTrackingTag({ path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' }, }); // Rename, or toggle Advanced Matching / first-party cookies / data-use await zernio.trackingtags.updateTrackingTag({ path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' }, body: { name: 'Website Pixel (renamed)', enableAutomaticMatching: true }, }); ``` ```python # Every pixel the connected ad account can see (pass ad_account_id to scope to one) listing = client.tracking_tags.list_tracking_tags(account_id="ACCOUNT_ID") # One pixel — includes its install code, last_fired_time, owner_business_id one = client.tracking_tags.get_tracking_tag( account_id="ACCOUNT_ID", tag_id="1729525464415281" ) # Rename, or toggle Advanced Matching / first-party cookies / data-use client.tracking_tags.update_tracking_tag( account_id="ACCOUNT_ID", tag_id="1729525464415281", name="Website Pixel (renamed)", enable_automatic_matching=True, ) ``` ```bash curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags" \ -H "Authorization: Bearer YOUR_API_KEY" curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281" \ -H "Authorization: Bearer YOUR_API_KEY" curl -X PATCH "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Website Pixel (renamed)","enableAutomaticMatching":true}' ``` ### Share a pixel with another ad account By default a pixel only works in the ad account it was created on. Share it with others so their campaigns and audiences can use it (`listTrackingTagSharedAccounts` and `removeTrackingTagSharedAccount` round out the set): ```typescript await zernio.trackingtags.addTrackingTagSharedAccount({ path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' }, body: { adAccountId: 'act_9876543210' }, }); ``` ```python client.tracking_tags.add_tracking_tag_shared_account( account_id="ACCOUNT_ID", tag_id="1729525464415281", ad_account_id="act_9876543210", ) ``` ```bash curl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281/shared-accounts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"adAccountId":"act_9876543210"}' ``` For aggregated event counts, call [`zernio.trackingtags.getTrackingTagStats(...)`](/tracking-tags/get-tracking-tag-stats) — `aggregation` is `event` by default; also `host`, `url`, `device_type`, and more. **Things to know about Meta Pixels:** - **Not idempotent.** Each create call makes a new pixel — don't retry blindly on timeout. - **One pixel per ad account on create.** `POST .../tracking-tags` returns a 400 (`A pixel already exists for this account`) if that ad account already has one. A Business Manager can own many pixels, just not two on the same ad account via this call. - **No delete.** Meta has no API to delete a pixel. To stop using one, unshare it (`DELETE .../shared-accounts`) or disable it in Events Manager. - **Personal ad accounts can't share.** A pixel created on an ad account that isn't owned by a Business Manager comes back with `ownerBusinessId: null` and can't be shared with other ad accounts (Meta rejects the share). Claim the ad account into a Business Manager first. - **Not exposed (needs `business_management`):** sharing a pixel with a partner/agency business, and assigning system users to a pixel. --- # Media Requirements Image and video requirements for Meta ads import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Media Requirements | Type | Format | Max Size | Notes | |------|--------|----------|-------| | Feed Image | JPEG, PNG | 30 MB | 1080x1080 or 1200x628 recommended | | Reels Video | MP4, MOV | 4 GB | 9:16 vertical, max 90 sec | | Story | JPEG, PNG, MP4 | 30 MB / 4 GB | 9:16 vertical | | Carousel | JPEG, PNG, MP4 | 30 MB/card | 2-10 cards | --- # Targeting Interest, demographic, and behavior targeting import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Targeting Use `/v1/ads/interests?platform=metaads&q=devops` to search interest IDs, and `/v1/ads/targeting/search?type=city&q=Amsterdam&countryCode=NL` to look up city / region keys. | Field | Type | Description | |-------|------|-------------| | `ageMin` | number | Minimum age (13-65) | | `ageMax` | number | Maximum age (13-65) | | `countries` | string[] | ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` when no `cities` or `regions` are provided. | | `cities` | object[] | City-level targeting. Each entry: `{ key, radius?, distance_unit? }` where `key` is from `/v1/ads/targeting/search`, and `radius` + `distance_unit` (`"kilometer"` or `"mile"`) must be set together. | | `regions` | object[] | Region/state-level targeting. Each entry: `{ key }` from `/v1/ads/targeting/search?type=region`. | | `interests` | object[] | `{ id, name }` from `/v1/ads/interests` | | `gender` | string | `"all"`, `"male"`, or `"female"` (default `"all"`) | On `/v1/ads/create`, targeting fields are flat at the top level (`ageMin`, `ageMax`, `countries`, `cities`, `regions`, `interests`). On `/v1/ads/boost`, they live inside a `targeting: { ... }` object that's passed through to Meta as-is. ### Looking up city / region keys Meta's `cities` and `regions` use opaque IDs that aren't derivable from the name. Use the targeting search helper to resolve them: ```typescript // Find Meta's key for Amsterdam in NL const { results } = await zernio.ads.searchAdTargetingLocations({ accountId: 'FB_ACCOUNT_ID', q: 'Amsterdam', type: 'city', countryCode: 'NL', }); // results[0].key → "2759794" // Use the key on /v1/ads/create await zernio.ads.createStandaloneAd({ body: { accountId: 'FB_ACCOUNT_ID', adAccountId: 'act_1234567890', name: 'Amsterdam launch', goal: 'traffic', budgetAmount: 20, budgetType: 'lifetime', endDate: '2026-05-15T00:00:00Z', headline: 'Hello Amsterdam', body: 'Try us this week.', linkUrl: 'https://example.com', imageUrl: 'https://cdn.example.com/promo.jpg', callToAction: 'LEARN_MORE', cities: [{ key: '2759794', radius: 25, distance_unit: 'kilometer' }], dsaBeneficiary: 'Acme BV', dsaPayor: 'Acme BV', }}); ``` ```python results = client.ads.search_ad_targeting_locations( account_id="FB_ACCOUNT_ID", q="Amsterdam", type="city", country_code="NL", )["results"] # results[0]["key"] → "2759794" client.ads.create_standalone_ad( account_id="FB_ACCOUNT_ID", ad_account_id="act_1234567890", name="Amsterdam launch", goal="traffic", budget_amount=20, budget_type="lifetime", end_date="2026-05-15T00:00:00Z", headline="Hello Amsterdam", body="Try us this week.", link_url="https://example.com", image_url="https://cdn.example.com/promo.jpg", call_to_action="LEARN_MORE", cities=[{"key": "2759794", "radius": 25, "distance_unit": "kilometer"}], dsa_beneficiary="Acme BV", dsa_payor="Acme BV", ) ``` ```bash # 1. Look up the city key curl "https://zernio.com/api/v1/ads/targeting/search?accountId=FB_ACCOUNT_ID&q=Amsterdam&type=city&countryCode=NL" \ -H "Authorization: Bearer YOUR_API_KEY" # 2. Use the key on /v1/ads/create curl -X POST "https://zernio.com/api/v1/ads/create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "FB_ACCOUNT_ID", "adAccountId": "act_1234567890", "name": "Amsterdam launch", "goal": "traffic", "budgetAmount": 20, "budgetType": "lifetime", "endDate": "2026-05-15T00:00:00Z", "headline": "Hello Amsterdam", "body": "Try us this week.", "linkUrl": "https://example.com", "imageUrl": "https://cdn.example.com/promo.jpg", "callToAction": "LEARN_MORE", "cities": [{ "key": "2759794", "radius": 25, "distance_unit": "kilometer" }], "dsaBeneficiary": "Acme BV", "dsaPayor": "Acme BV" }' ``` `type` accepts `city`, `region`, `country`, `subcity`, `neighborhood`, `zip`, `metro_area`, `geo_market`. Pass `countryCode` to disambiguate when the same name exists in multiple countries (e.g. there are several "Eindhoven"s globally). Don't combine `cities` with the same `countries`, Meta returns a "locations overlap" error because the city is already inside the country boundary. Either drop the country, or scope `countries` to a different country than the cities you're targeting. --- # Ad URL Tracking Tags Attach click-URL tracking parameters to ads import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Ad URL tracking tags Separate from the [Meta Pixels](/platforms/meta-ads/pixels#meta-pixels) above (those are the measurement pixel). These are the `url_tags` appended to an ad's click destination, the UTM-style attribution params on the landing-page URL. Read them to audit a campaign, or update them to fix missing or wrong tags. Read the current tags off any ad (works on synced ads too): ```typescript const { data } = await zernio.ads.getAdTrackingTags({ path: { adId: 'AD_ID' } }); // { platform: 'facebook', level: 'creative', urlTags: 'utm_source=meta&utm_medium=cpc', templateUrlSpec: null } ``` ```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" ``` Updating tags on Meta rebuilds the creative behind the scenes (Meta creatives are immutable, so changing `url_tags` means creating a fresh creative and repointing the ad). Zernio does this for you and **preserves the existing creative verbatim**, re-posting its current headline, body, CTA, and image (reused by hash, never re-uploaded) with only the new tags added. So you send `urlTags` alone, there's nothing to read back or supply: ```typescript const { data } = await zernio.ads.updateAdTrackingTags({ path: { adId: 'AD_ID' }, body: { urlTags: [ { key: 'utm_source', value: 'meta' }, { key: 'utm_medium', value: 'cpc' }, ], }, }); ``` ```python data = client.ads.update_ad_tracking_tags( ad_id="AD_ID", url_tags=[ {"key": "utm_source", "value": "meta"}, {"key": "utm_medium", "value": "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 '{ "urlTags": [ { "key": "utm_source", "value": "meta" }, { "key": "utm_medium", "value": "cpc" } ] }' ``` **`creative` is optional.** Omit it to preserve the existing creative (above). Pass it (`headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`) only to rebuild the creative explicitly, or for the few creatives Zernio can't auto-preserve. SHARE / page-post-boost / dark posts and Advantage+ asset-feed creatives have their underlying spec stripped by Meta, so they return a `422` asking for `creative`. --- # Analytics & Engagement Tweet analytics and engagement actions import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## 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 | ✅ | ```typescript const analytics = await zernio.analytics.getAnalytics({ platform: 'twitter', fromDate: '2024-01-01', toDate: '2024-01-31' }); console.log(analytics.posts); ``` ```python analytics = client.analytics.get_analytics( platform="twitter", from_date="2024-01-01", to_date="2024-01-31" ) print(analytics["posts"]) ``` ```bash curl "https://zernio.com/api/v1/analytics?platform=twitter&fromDate=2024-01-01&toDate=2024-01-31" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Engagement Retweet, bookmark, and follow directly through the API. All engagement endpoints share a **50 requests per 15-min window** rate limit. Retweets also share the 300/3hr creation limit with tweet creation. ```typescript // Retweet await zernio.twitterengagement.retweetPost({ accountId: 'YOUR_ACCOUNT_ID', tweetId: '1748391029384756102' }); // Bookmark await zernio.twitterengagement.bookmarkPost({ accountId: 'YOUR_ACCOUNT_ID', tweetId: '1748391029384756102' }); // Follow await zernio.twitterengagement.followUser({ accountId: 'YOUR_ACCOUNT_ID', targetUserId: '123456789' }); ``` ```python # Retweet client.twitter_engagement.retweet_post( account_id="YOUR_ACCOUNT_ID", tweet_id="1748391029384756102" ) # Bookmark client.twitter_engagement.bookmark_post( account_id="YOUR_ACCOUNT_ID", tweet_id="1748391029384756102" ) # Follow client.twitter_engagement.follow_user( account_id="YOUR_ACCOUNT_ID", target_user_id="123456789" ) ``` ```bash # Retweet curl -X POST https://zernio.com/api/v1/twitter/retweet \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_ACCOUNT_ID", "tweetId": "1748391029384756102"}' # Bookmark curl -X POST https://zernio.com/api/v1/twitter/bookmark \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_ACCOUNT_ID", "tweetId": "1748391029384756102"}' # Follow curl -X POST https://zernio.com/api/v1/twitter/follow \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_ACCOUNT_ID", "targetUserId": "123456789"}' ``` | Action | Endpoint | Undo | |--------|----------|------| | Retweet | `POST /v1/twitter/retweet` | `DELETE /v1/twitter/retweet` | | Bookmark | `POST /v1/twitter/bookmark` | `DELETE /v1/twitter/bookmark` | | Follow | `POST /v1/twitter/follow` | `DELETE /v1/twitter/follow` | See [Twitter Engagement API Reference](/twitter-engagement/retweet-post) for full endpoint documentation. --- # Fields, Geo & Polls Platform-specific fields, geo-restriction, polls, and media URL requirements import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Platform-Specific Fields All fields go inside `platformSpecificData` on the Twitter platform entry. | Field | Type | Description | |-------|------|-------------| | `replyToTweetId` | string | ID of an existing tweet to reply to. The published tweet will appear as a reply in that tweet's thread. For threads, only the first tweet replies to the target; subsequent tweets chain normally. | | `quoteTweetId` | string | ID (or full status URL) of an existing tweet to quote-repost. Mutually exclusive with media and poll. For threads, applies to the first tweet only. | | `replySettings` | "following" \| "mentionedUsers" \| "subscribers" \| "verified" | Controls who can reply to the tweet. Omit for default (everyone can reply). For threads, applies to the first tweet only. Cannot be combined with `replyToTweetId`. | | `threadItems` | Array\<\{content, mediaItems?\}\> | Complete sequence of tweets in a thread. The first item becomes the root tweet and must be provided as `threadItems[0]`. When `threadItems` is provided, top-level `content` is for display/search only and is NOT published. | | `poll` | object | Create a poll with this tweet. Mutually exclusive with media attachments and threads. | | `poll.options` | string[] | Poll options (2-4 choices, max 25 characters each). | | `poll.duration_minutes` | number | Poll duration in minutes (5 min to 7 days). | | `longVideo` | boolean | Enable long video uploads (over 140 seconds) using amplify_video. Requires X Premium; may require allowlisting. | | `geoRestriction` | object | Restrict media visibility to specific countries. Only applies when media is attached (ignored for text-only tweets). `geoRestriction.countries`: array of uppercase ISO 3166-1 alpha-2 codes, max 25. The media is hidden in restricted countries; the tweet text remains visible globally. | | `paidPartnership` | boolean | When true, label the post as a paid partnership / paid promotion. For threads, applies to the root tweet only. Field availability may depend on your X API access tier. | | `madeWithAi` | boolean | When true, label the post as containing AI-generated media (not AI-written text). For threads, applies to the root tweet only. | | `sensitiveMedia` | object | Marks attached media with a sensitive-content warning (requires media; ignored for text-only tweets). At least one flag must be true. | | `sensitiveMedia.adultContent` | boolean | Content contains adult material. | | `sensitiveMedia.graphicViolence` | boolean | Content depicts graphic violence. | | `sensitiveMedia.other` | boolean | Content has other sensitive characteristics. | ## Geo-Restriction Restrict who can see your tweet's media by country. This applies at the media level: the media is hidden for users outside the specified countries, but the tweet text remains visible globally. Requires media to be attached. ```json { "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "geoRestriction": { "countries": ["US", "ES"] } } }], "mediaItems": [{"type": "image", "url": "https://example.com/photo.jpg"}] } ``` ## Paid partnership, AI labels, and sensitive media Zernio supports additional X labels via `platformSpecificData`. > **Note:** `paidPartnership` and `madeWithAi` apply to the root tweet only (for threads). ### Paid partnership label Set `platformSpecificData.paidPartnership: true` to label the post as a paid partnership / paid promotion. > **Note:** Field availability may depend on your X API access tier. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Sponsored: our new feature is live", "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "paidPartnership": true } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Sponsored: our new feature is live', platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { paidPartnership: true } }], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Sponsored: our new feature is live", platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "paidPartnership": True } }], publish_now=True ) post = result.post print(f"Tweet posted! {post['_id']}") ``` ### AI-generated media label Set `platformSpecificData.madeWithAi: true` to label the post as containing AI-generated media. > **Note:** Per X, this label is for AI-generated media, not AI-written text. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "AI-generated image (labeled)", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/ai-image.jpg"} ], "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "madeWithAi": true } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'AI-generated image (labeled)', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/ai-image.jpg' }], platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { madeWithAi: true } }], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="AI-generated image (labeled)", media_items=[{"type": "image", "url": "https://cdn.example.com/ai-image.jpg"}], platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "madeWithAi": True } }], publish_now=True ) post = result.post print(f"Tweet posted! {post['_id']}") ``` ### Sensitive media warning Use `platformSpecificData.sensitiveMedia` to mark attached media with a sensitive-content warning. > **Note:** Requires media (ignored for text-only tweets). At least one flag must be `true`. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Sensitive media example", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "sensitiveMedia": { "adultContent": true, "graphicViolence": false, "other": false } } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Sensitive media example', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/photo.jpg' }], platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { sensitiveMedia: { adultContent: true, graphicViolence: false, other: false } } }], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Sensitive media example", media_items=[{"type": "image", "url": "https://cdn.example.com/photo.jpg"}], platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "sensitiveMedia": { "adultContent": True, "graphicViolence": False, "other": False } } }], publish_now=True ) post = result.post print(f"Tweet posted! {post['_id']}") ``` ## Polls Create a Twitter/X poll by providing `platformSpecificData.poll`. > **Note:** Polls are mutually exclusive with `mediaItems` and `platformSpecificData.threadItems`. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Which feature should we ship next?", "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "poll": { "options": ["Dark mode", "New analytics", "More integrations"], "duration_minutes": 1440 } } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Which feature should we ship next?', platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { poll: { options: ['Dark mode', 'New analytics', 'More integrations'], duration_minutes: 1440 } } }], publishNow: true }); console.log('Poll posted!', post._id); ``` ```python result = client.posts.create_post( content="Which feature should we ship next?", platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "poll": { "options": ["Dark mode", "New analytics", "More integrations"], "duration_minutes": 1440 } } }], publish_now=True ) post = result.post print(f"Poll posted! {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. 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. --- # Inbox Direct messages in the inbox import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Inbox > **Included** — Inbox (DMs, comments, reviews) is bundled with every paid account on the [Usage plan](/pricing). Twitter/X supports DMs and comments through the unified Inbox API. ### Direct Messages | Feature | Supported | |---------|-----------| | List conversations | ✅ | | Fetch messages | ✅ | | Send text messages | ✅ | | Send attachments | ✅ (images, videos - max 25 MB) | | Archive/unarchive | ❌ | #### Create conversation (send a DM) Use `POST /v1/inbox/conversations` to start a new DM conversation (or append to an existing thread). > **Note:** X DM write endpoints require **X API Pro tier ($5,000/month)** or Enterprise access. This applies to BYOK users who provide their own X API credentials. > **Note:** By default, Zernio checks DM eligibility (recipient `receives_your_dm`). If the recipient does not accept DMs, the API returns `422` with code `DM_NOT_ALLOWED`. You can skip this check with `skipDmCheck: true`. ```bash curl -X POST https://zernio.com/api/v1/inbox/conversations \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "participantUsername": "targetuser", "message": "Hello from Zernio Inbox API!" }' ``` ```typescript const result = await zernio.messages.createInboxConversation({ accountId: 'YOUR_ACCOUNT_ID', participantUsername: 'targetuser', message: 'Hello from Zernio Inbox API!' }); console.log('DM sent:', result.data.conversationId, result.data.messageId); ``` ```python result = client.messages.create_inbox_conversation( account_id="YOUR_ACCOUNT_ID", participant_username="targetuser", message="Hello from Zernio Inbox API!" ) print("DM sent:", result["data"]["conversationId"], result["data"]["messageId"]) ``` ### Comments | Feature | Supported | |---------|-----------| | List comments on posts | ✅ | | Post new comment | ✅ | | Reply to comments | ✅ | | Delete comments | ✅ | | Like/unlike comments | ✅ | | Hide/unhide comments | ✅ | ### Limitations - **Encrypted X Chat DMs not accessible** - X has replaced traditional DMs with end-to-end encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not returned by X's API. This means some conversations may show only outgoing messages or appear empty. This is an [X platform limitation](https://help.x.com/en/using-x/about-chat) that affects all third-party applications. - **DM permissions** - DMs require `dm.read` and `dm.write` scopes - **Reply search** - Uses cached conversation threads (2-min TTL) to manage rate limits - **Cached DMs** - Conversations are cached with a 15-min TTL See [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) API Reference for endpoint details. --- # Twitter/X Schedule and automate Twitter/X posts with Zernio API import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { Cards, Card } from 'fumadocs-ui/components/card'; import { PlatformCapabilities } from '@/components/platform-capabilities'; ## Quick Reference ## Before You Start Twitter has a strict **280 character limit** for free accounts. URLs always count as 23 characters regardless of actual length. Emojis count as 2 characters. If you're cross-posting from platforms with higher limits (LinkedIn 3,000, Facebook 63,000), use `customContent` to provide a shorter Twitter version or your post **WILL** fail. Additional requirements: - Duplicate tweets are rejected (even very similar content) - Free accounts: 280 characters, Premium accounts: 25,000 characters **X API costs are passed through at exact rate, with zero markup.** Unlike other platforms, X (Twitter) operates on a pay-per-call API model. Every X API operation Zernio performs on your behalf — posting, reading analytics, sending DMs, fetching followers — is metered against your account at X's published price. Common rates that appear on your invoice: - **Posts: Read** — $0.005 per resource (analytics, post lookups) - **Content: Create** — $0.015 per request (publishing a tweet) - **Content: Create with URL** — $0.200 per request (tweets containing http/https links — yes, X charges 13× more for these) - **DM: Read** / **DM: Send** — $0.010 / $0.015 per resource These appear as itemized line items on your monthly invoice. Zernio's per-call price always equals X's published price, never higher. The full rate table is at [docs.x.com/x-api/getting-started/pricing](https://docs.x.com/x-api/getting-started/pricing). You can set a **monthly X spend cap** from the dashboard's Billing tab to prevent surprise bills. At 80% of the cap you get a warning email; at 100% Zernio automatically pauses X analytics + inbox polling for the rest of the period. ## Related Endpoints - [Connect Twitter 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 - [Twitter Engagement](/twitter-engagement/retweet-post) - Retweet, bookmark, follow - [Messages](/messages/list-inbox-conversations) and [Comments](/comments/list-inbox-comments) ## In This Section --- # Media & Video Media requirements and long video uploads (Premium) import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Media Requirements ### Images | Property | Requirement | |----------|-------------| | **Max images** | 4 per tweet | | **Formats** | JPEG, PNG, WebP, GIF | | **Max file size** | 5 MB (images), 15 MB (GIFs) | | **Min dimensions** | 4 x 4 px | | **Max dimensions** | 8192 x 8192 px | | **Recommended** | 1200 x 675 px (16:9) | #### Aspect Ratios | Type | Ratio | Dimensions | |------|-------|------------| | Landscape | 16:9 | 1200 x 675 px | | Square | 1:1 | 1200 x 1200 px | | Portrait | 4:5 | 1080 x 1350 px | ### GIFs | Property | Requirement | |----------|-------------| | **Max per tweet** | 1 (consumes all 4 image slots) | | **Max file size** | 15 MB | | **Max dimensions** | 1280 x 1080 px | | **Behavior** | Auto-plays in timeline | ### Videos | Property | Requirement | |----------|-------------| | **Max videos** | 1 per tweet | | **Formats** | MP4, MOV | | **Max file size** | 512 MB | | **Max duration** | 140 seconds (2 min 20 sec) standard; up to ~10 minutes with Premium long video (see below) | | **Min duration** | 0.5 seconds | | **Min dimensions** | 32 x 32 px | | **Max dimensions** | 1920 x 1200 px | | **Frame rate** | 40 fps max | | **Bitrate** | 25 Mbps max | ## Long Video Uploads (Premium) By default, X limits API video uploads to **140 seconds**. If the connected X account has an active **X Premium** subscription, you can enable long video uploads (over 140 seconds) by setting `platformSpecificData.longVideo: true`. > **Note:** Not all Premium accounts have API long-video access, as X may require separate allowlisting. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Long video upload (Premium)", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/long-video.mp4"} ], "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "longVideo": true } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Long video upload (Premium)', mediaItems: [{ type: 'video', url: 'https://cdn.example.com/long-video.mp4' }], platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { longVideo: true } }], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Long video upload (Premium)", media_items=[{"type": "video", "url": "https://cdn.example.com/long-video.mp4"}], platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": {"longVideo": True} }], publish_now=True ) post = result.post print(f"Tweet posted! {post['_id']}") ``` #### 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, 128 kbps | --- # Posts & Editing Create tweets, threads, and edit published tweets import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Start Post a tweet in under 60 seconds: ```typescript const { post } = await zernio.posts.createPost({ content: 'Hello from Zernio API!', platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Hello from Zernio API!", platforms=[ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Tweet 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": "Hello from Zernio API!", "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ## Content Types ### Text Tweet A simple text-only tweet. Keep it under 280 characters for free accounts. ```typescript const { post } = await zernio.posts.createPost({ content: 'Just shipped a new feature. Check it out!', platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Just shipped a new feature. Check it out!", platforms=[ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Tweet 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": "Just shipped a new feature. Check it out!", "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Tweet with Image Attach up to 4 images per tweet. JPEG, PNG, WebP, and GIF formats are supported. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this photo!', mediaItems: [ { type: 'image', url: 'https://cdn.example.com/photo.jpg' } ], platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Tweet with image posted!', 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": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Tweet with image 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": "Check out this photo!", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/photo.jpg"} ], "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Tweet with Video Attach a single video per tweet. MP4 and MOV formats, up to 512 MB, max 140 seconds (standard uploads). ```typescript const { post } = await zernio.posts.createPost({ content: 'New product demo', mediaItems: [ { type: 'video', url: 'https://cdn.example.com/demo.mp4' } ], platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Tweet with video posted!', 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": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Tweet with 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 demo", "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/demo.mp4"} ], "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Tweet with GIF Only 1 GIF per tweet (it consumes all 4 image slots). Max 15 MB, 1280 x 1080 px. Animated GIFs auto-play in the timeline. ```typescript const { post } = await zernio.posts.createPost({ content: 'Check out this animation!', mediaItems: [ { type: 'gif', url: 'https://cdn.example.com/animation.gif' } ], platforms: [ { platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID' } ], publishNow: true }); console.log('Tweet with GIF posted!', post._id); ``` ```python result = client.posts.create_post( content="Check out this animation!", media_items=[ {"type": "gif", "url": "https://cdn.example.com/animation.gif"} ], platforms=[ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], publish_now=True ) post = result.post print(f"Tweet with GIF 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": "Check out this animation!", "mediaItems": [ {"type": "gif", "url": "https://cdn.example.com/animation.gif"} ], "platforms": [ {"platform": "twitter", "accountId": "YOUR_ACCOUNT_ID"} ], "publishNow": true }' ``` ### Thread (Multi-Tweet) Create Twitter threads with multiple connected tweets using `platformSpecificData.threadItems`. Each item becomes a reply to the previous tweet and can have its own content 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 tweet as `threadItems[0]`. ## Edit Published Tweets Zernio supports editing **published** tweets via X's edit feature. > **Note:** Editing is currently supported for **X (Twitter) only**, requires an active **X Premium** subscription on the connected account, must be within **1 hour** of the original publish time, is limited to **5 edits per tweet** (enforced by X), and supports **text-only** edits (media changes are not supported). ```bash curl -X POST https://zernio.com/api/v1/posts/YOUR_POST_ID/edit \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "twitter", "content": "Updated tweet text with corrected information" }' ``` ```typescript const result = await zernio.posts.editPost({ postId: 'YOUR_POST_ID', platform: 'twitter', content: 'Updated tweet text with corrected information' }); console.log('Edited tweet:', result.id, result.url); ``` ```python result = client.posts.edit_post( post_id="YOUR_POST_ID", platform="twitter", content="Updated tweet text with corrected information" ) print("Edited tweet:", result["id"], result["url"]) ``` --- # Limits & Errors What you cannot do and common errors import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## What You Can't Do These features are not available through Twitter's API: - Create Spaces - Post to Communities - Pin tweets to profile - Add Twitter Cards (must be configured on the destination URL via meta tags) - Post as a personal DM broadcast ## Common Errors Twitter/X has a **21.3% failure rate** across Zernio's platform (17,385 failures out of 81,796 attempts). Here are the most frequent errors and how to fix them: | Error | What it means | How to fix | |-------|---------------|------------| | "Tweet text is too long (X characters). Twitter's limit is 280 characters. Note: URLs count as 23 characters." | Exceeds 280 character limit for free accounts | Shorten text or use `customContent` for Twitter. Remember: URLs = 23 chars, emojis = 2 chars. | | "X (Twitter) does not allow duplicate tweets" | Same or very similar content was already posted | Modify the text, even slightly. | | "Rate limit hit. Please wait 10 minutes" | Zernio's velocity limit was triggered | Reduce posting frequency. Space posts at least 4 minutes apart. | | "Missing tweet.write scope" / "forbidden" | OAuth token lacks required permissions | Reconnect the account with all required scopes. | | Token expired | OAuth access was revoked or expired | Reconnect the account. Subscribe to the `account.disconnected` webhook to catch this proactively. | --- # Replies & Quotes Reply tweets and quote reposts import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Reply Tweets Use `platformSpecificData.replyToTweetId` to publish a tweet as a reply to an existing tweet. > **Note:** `replyToTweetId` cannot be combined with `replySettings`. For threads, only the first tweet replies to the target; subsequent tweets chain normally. ## Quote Tweets (Quote Reposts) Use `platformSpecificData.quoteTweetId` to publish a quote tweet (quote repost) of an existing tweet. > **Note:** `quoteTweetId` is mutually exclusive with `mediaItems` and `platformSpecificData.poll`. For threads, it applies to the first tweet only. > **Note:** X only permits quoting your own posts or posts you are mentioned in / part of the conversation thread of; quoting an arbitrary other account's post is rejected by X. > **Note:** Quoting via `quoteTweetId` is billed at the standard create rate ($0.015). Pasting a tweet URL into the text is billed at the URL rate ($0.20). ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Adding context via quote tweet", "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "quoteTweetId": "1748391029384756102" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Adding context via quote tweet', platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { quoteTweetId: '1748391029384756102' } }], publishNow: true }); console.log('Quote tweet posted!', post._id); ``` ```python result = client.posts.create_post( content="Adding context via quote tweet", platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "quoteTweetId": "1748391029384756102" } }], publish_now=True ) post = result.post print(f"Quote tweet 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": "Replying via Zernio API", "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "replyToTweetId": "1748391029384756102" } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ content: 'Replying via Zernio API', platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { replyToTweetId: '1748391029384756102' } }], publishNow: true }); console.log('Reply posted!', post._id); ``` ```python result = client.posts.create_post( content="Replying via Zernio API", platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "replyToTweetId": "1748391029384756102" } }], publish_now=True ) post = result.post print(f"Reply posted! {post['_id']}") ``` ### Reply Thread To reply with a thread, combine `replyToTweetId` with `threadItems`. Only the first thread item replies to the target tweet. ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platforms": [{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "replyToTweetId": "1748391029384756102", "threadItems": [ {"content": "1/ Reply thread: first tweet replies to the target"}, {"content": "2/ Follow-up tweet in the same thread"} ] } }], "publishNow": true }' ``` ```typescript const { post } = await zernio.posts.createPost({ platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { replyToTweetId: '1748391029384756102', threadItems: [ { content: '1/ Reply thread: first tweet replies to the target' }, { content: '2/ Follow-up tweet in the same thread' } ] } }], publishNow: true }); console.log('Reply thread posted!', post._id); ``` ```python result = client.posts.create_post( platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "replyToTweetId": "1748391029384756102", "threadItems": [ {"content": "1/ Reply thread: first tweet replies to the target"}, {"content": "2/ Follow-up tweet in the same thread"} ] } }], publish_now=True ) post = result.post print(f"Reply thread posted! {post['_id']}") ``` ```typescript const { post } = await zernio.posts.createPost({ platforms: [{ platform: 'twitter', accountId: 'YOUR_ACCOUNT_ID', platformSpecificData: { threadItems: [ { content: '1/ Starting a thread about API design', mediaItems: [{ type: 'image', url: 'https://cdn.example.com/image1.jpg' }] }, { content: '2/ First, always use proper HTTP methods...' }, { content: '3/ Second, version your APIs from day one...' }, { content: '4/ Finally, document everything! /end' } ] } }], publishNow: true }); console.log('Thread posted!', post._id); ``` ```python result = client.posts.create_post( platforms=[{ "platform": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "1/ Starting a thread about API design", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/image1.jpg"}] }, {"content": "2/ First, always use proper HTTP methods..."}, {"content": "3/ Second, version your APIs from day one..."}, {"content": "4/ Finally, document everything! /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": "twitter", "accountId": "YOUR_ACCOUNT_ID", "platformSpecificData": { "threadItems": [ { "content": "1/ Starting a thread about API design", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/image1.jpg"}] }, { "content": "2/ First, always use proper HTTP methods..." }, { "content": "3/ Second, version your APIs from day one..." }, { "content": "4/ Finally, document everything! /end" } ] } }], "publishNow": true }' ``` --- # Broadcasts Send WhatsApp template messages to many recipients with per-recipient variables and scheduling import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Quick Start Send a WhatsApp template message to multiple recipients. Create a broadcast, add phone numbers, and send: ```typescript import Zernio from '@zernio/node'; const zernio = new Zernio(); // Step 1: Create a WhatsApp broadcast with a Meta-approved template const { data: broadcast } = await zernio.broadcasts.createBroadcast({ body: { profileId: 'YOUR_PROFILE_ID', accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', platform: 'whatsapp', name: 'Welcome Campaign', template: { name: 'hello_world', language: 'en' } } }); // Step 2: Add WhatsApp recipients by phone number await zernio.broadcasts.addBroadcastRecipients({ path: { broadcastId: broadcast.broadcast.id }, body: { phones: ['+1234567890', '+0987654321'] } }); // Step 3: Send the WhatsApp broadcast const { data: result } = await zernio.broadcasts.sendBroadcast({ path: { broadcastId: broadcast.broadcast.id } }); console.log(`Sent: ${result.sent}, Failed: ${result.failed}`); ``` ```python from zernio import Zernio client = Zernio() # Step 1: Create a WhatsApp broadcast with a Meta-approved template broadcast = client.broadcasts.create_broadcast( profile_id='YOUR_PROFILE_ID', account_id='YOUR_WHATSAPP_ACCOUNT_ID', platform='whatsapp', name='Welcome Campaign', template={ 'name': 'hello_world', 'language': 'en' } ) # Step 2: Add WhatsApp recipients by phone number client.broadcasts.add_broadcast_recipients( broadcast_id=broadcast.broadcast.id, phones=['+1234567890', '+0987654321'] ) # Step 3: Send the WhatsApp broadcast result = client.broadcasts.send_broadcast( broadcast_id=broadcast.broadcast.id ) print(f"Sent: {result.sent}, Failed: {result.failed}") ``` ```bash # Step 1: Create a WhatsApp broadcast BROADCAST_ID=$(curl -s -X POST https://zernio.com/api/v1/broadcasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "platform": "whatsapp", "name": "Welcome Campaign", "template": { "name": "hello_world", "language": "en" } }' | jq -r '.broadcast.id') # Step 2: Add WhatsApp recipients by phone number curl -X POST "https://zernio.com/api/v1/broadcasts/$BROADCAST_ID/recipients" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phones": ["+1234567890", "+0987654321"]}' # Step 3: Send the WhatsApp broadcast curl -X POST "https://zernio.com/api/v1/broadcasts/$BROADCAST_ID/send" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Broadcasts Send WhatsApp template messages to many recipients at once. Broadcasts support per-recipient template variables, scheduling, and delivery tracking (sent, delivered, read). ### Create a Broadcast Create a WhatsApp broadcast by specifying your WABA account and a Meta-approved template: ```typescript const { data } = await zernio.broadcasts.createBroadcast({ body: { profileId: 'YOUR_PROFILE_ID', accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', platform: 'whatsapp', name: 'January Newsletter', template: { name: 'monthly_update', language: 'en', components: [{ type: 'body', parameters: [{ type: 'text', text: '{{1}}' }] }] } } }); console.log('Broadcast created:', data.broadcast.id); ``` ```python response = client.broadcasts.create_broadcast( profile_id='YOUR_PROFILE_ID', account_id='YOUR_WHATSAPP_ACCOUNT_ID', platform='whatsapp', name='January Newsletter', template={ 'name': 'monthly_update', 'language': 'en', 'components': [{ 'type': 'body', 'parameters': [{'type': 'text', 'text': '{{1}}'}] }] } ) print(f"Broadcast created: {response.broadcast.id}") ``` ```bash curl -X POST https://zernio.com/api/v1/broadcasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "platform": "whatsapp", "name": "January Newsletter", "template": { "name": "monthly_update", "language": "en", "components": [{ "type": "body", "parameters": [{"type": "text", "text": "{{1}}"}] }] } }' ``` ### Template Variables WhatsApp templates use numbered placeholders (`{{1}}`, `{{2}}`, and so on) defined in the template body when you create it, for example `Hi {{1}}, your order {{2}} has been confirmed!`. Only numbered placeholders are supported, not named ones. To fill those placeholders, add a `variableMapping` to the broadcast's `template`. It maps each placeholder position to a contact field or a static value, and Zernio resolves it for every recipient automatically at send time (so `{{1}}` becomes each contact's own name, `{{2}}` their order number, and so on): ```typescript const { data } = await zernio.broadcasts.createBroadcast({ body: { profileId: 'YOUR_PROFILE_ID', accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', platform: 'whatsapp', name: 'Order Confirmations', template: { name: 'order_confirmation', language: 'en', components: [{ type: 'body', parameters: [ { type: 'text', text: '{{1}}' }, { type: 'text', text: '{{2}}' } ] }], // Resolved per recipient at send time variableMapping: { '1': { field: 'name' }, // each contact's name '2': { field: 'custom', customValue: 'VIP-2025' } // same value for everyone } } } }); ``` ```python response = client.broadcasts.create_broadcast( profile_id='YOUR_PROFILE_ID', account_id='YOUR_WHATSAPP_ACCOUNT_ID', platform='whatsapp', name='Order Confirmations', template={ 'name': 'order_confirmation', 'language': 'en', 'components': [{ 'type': 'body', 'parameters': [ {'type': 'text', 'text': '{{1}}'}, {'type': 'text', 'text': '{{2}}'} ] }], # Resolved per recipient at send time 'variableMapping': { '1': {'field': 'name'}, # each contact's name '2': {'field': 'custom', 'customValue': 'VIP-2025'} # same value for everyone } } ) ``` ```bash curl -X POST https://zernio.com/api/v1/broadcasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "platform": "whatsapp", "name": "Order Confirmations", "template": { "name": "order_confirmation", "language": "en", "components": [{ "type": "body", "parameters": [ {"type": "text", "text": "{{1}}"}, {"type": "text", "text": "{{2}}"} ] }], "variableMapping": { "1": { "field": "name" }, "2": { "field": "custom", "customValue": "VIP-2025" } } } }' ``` Each `variableMapping` entry maps a position to one of these `field` values: | `field` | Resolves to | | --- | --- | | `name` | The contact's name (falls back to "there" if unset) | | `phone` | The recipient's phone number | | `email` | The contact's email | | `company` | The contact's company | | `custom` | The literal `customValue` string (same for every recipient) | The number of `variableMapping` entries must match the number of placeholders in the template body. A mismatch makes Meta reject the send with a parameter-count error (code 132000). ### Add Recipients Add WhatsApp recipients by phone number, existing contact IDs, or by matching your contact segment filters. Phone numbers are in E.164 format (e.g., `+1234567890`): ```typescript // Add WhatsApp recipients by phone number (auto-creates contacts) const { data } = await zernio.broadcasts.addBroadcastRecipients({ path: { broadcastId: 'BROADCAST_ID' }, body: { phones: ['+1555000111', '+1555000222'] } }); console.log(`Added: ${data.added}, Skipped: ${data.skipped}`); // Or add existing contacts by ID await zernio.broadcasts.addBroadcastRecipients({ path: { broadcastId: 'BROADCAST_ID' }, body: { contactIds: ['contact_1', 'contact_2'] } }); // Or auto-populate from your broadcast's segment filters (e.g., all contacts tagged "vip") await zernio.broadcasts.addBroadcastRecipients({ path: { broadcastId: 'BROADCAST_ID' }, body: { useSegment: true } }); ``` ```python # Add WhatsApp recipients by phone number (auto-creates contacts) response = client.broadcasts.add_broadcast_recipients( broadcast_id='BROADCAST_ID', phones=['+1555000111', '+1555000222'] ) print(f"Added: {response.added}, Skipped: {response.skipped}") # Or add existing contacts by ID client.broadcasts.add_broadcast_recipients( broadcast_id='BROADCAST_ID', contact_ids=['contact_1', 'contact_2'] ) # Or auto-populate from your broadcast's segment filters client.broadcasts.add_broadcast_recipients( broadcast_id='BROADCAST_ID', use_segment=True ) ``` ```bash # Add WhatsApp recipients by phone number curl -X POST "https://zernio.com/api/v1/broadcasts/BROADCAST_ID/recipients" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phones": ["+1555000111", "+1555000222"]}' # Or add existing contacts by ID curl -X POST "https://zernio.com/api/v1/broadcasts/BROADCAST_ID/recipients" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"contactIds": ["contact_1", "contact_2"]}' ``` ### Send a Broadcast Triggers immediate delivery of the WhatsApp template messages to all recipients: ```typescript const { data } = await zernio.broadcasts.sendBroadcast({ path: { broadcastId: 'BROADCAST_ID' } }); console.log(`Sent: ${data.sent}, Failed: ${data.failed}`); ``` ```python response = client.broadcasts.send_broadcast( broadcast_id='BROADCAST_ID' ) print(f"Sent: {response.sent}, Failed: {response.failed}") ``` ```bash curl -X POST "https://zernio.com/api/v1/broadcasts/BROADCAST_ID/send" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Schedule a Broadcast Schedule the WhatsApp broadcast for a future time. Zernio sends the template messages automatically at the scheduled time: ```typescript await zernio.broadcasts.scheduleBroadcast({ path: { broadcastId: 'BROADCAST_ID' }, body: { scheduledAt: '2025-02-01T10:00:00.000Z' } }); ``` ```python client.broadcasts.schedule_broadcast( broadcast_id='BROADCAST_ID', scheduled_at='2025-02-01T10:00:00.000Z' ) ``` ```bash curl -X POST "https://zernio.com/api/v1/broadcasts/BROADCAST_ID/schedule" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scheduledAt": "2025-02-01T10:00:00.000Z"}' ``` --- # Calling Inbound and outbound WhatsApp voice calling import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Calling WhatsApp Business Calling lets customers place a voice call to your business number from inside the WhatsApp chat. Zernio bridges the call to a destination you choose: a regular phone number, a SIP endpoint, or an AI voice agent (Vapi, Retell, or any WebSocket media server). Both inbound (customer calls you) and outbound (you call the customer) are supported. Calling is available on the [Usage plan](/pricing) only, so it requires usage-based billing to be active on your account. Zernio bills the carrier connection (plus an optional recording surcharge) at cost with zero markup; Meta bills its per-minute outbound rate directly to your WABA (see [Pricing & Costs](#pricing--costs)). **Outbound is restricted by country and messaging tier.** Meta blocks business-initiated calls on numbers registered in the US, Canada, Egypt, Vietnam, and Nigeria. Inbound calls to those numbers still work. For outbound elsewhere, Meta's production guidance requires the number to be at the 2,000-recipient daily messaging tier (`TIER_2K`); below that, outbound may be rate-limited by Meta. ### Get Calling Config Read the current calling state for a connected number. Returns the `phoneNumberDocId` you need for the enable/update/disable calls, along with the forwarding destination and recording flag. ```typescript const { data } = await zernio.whatsappcalling.getWhatsAppCallingConfig({ query: { accountId: 'YOUR_ACCOUNT_ID' } }); console.log(data.phoneNumberDocId, data.callingEnabled, data.forwardTo); ``` ```python response = client.whatsapp_calling.get_whats_app_calling_config(account_id='YOUR_ACCOUNT_ID') print(response['phoneNumberDocId'], response['callingEnabled'], response['forwardTo']) ``` ```bash curl "https://zernio.com/api/v1/whatsapp/calling?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Enable Calling Turn calling on for a number and set the forwarding destination. The `id` in the path is the `phoneNumberDocId` from the config call above. `forwardTo` accepts `tel:+E164` (phone), `sip:...` (SIP endpoint), or `wss://...` (WebSocket media server / AI agent). ```typescript const { data } = await zernio.whatsappcalling.enableWhatsAppCalling({ path: { id: 'PHONE_NUMBER_DOC_ID' }, body: { accountId: 'YOUR_ACCOUNT_ID', forwardTo: 'tel:+13105551234', recordingEnabled: false, } }); console.log('Calling enabled:', data.callingEnabled); ``` ```python response = client.whatsapp_calling.enable_whats_app_calling( id='PHONE_NUMBER_DOC_ID', account_id='YOUR_ACCOUNT_ID', forward_to='tel:+13105551234', recording_enabled=False, ) print('Calling enabled:', response['callingEnabled']) ``` ```bash curl -X POST "https://zernio.com/api/v1/whatsapp/phone-numbers/PHONE_NUMBER_DOC_ID/calling" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_ACCOUNT_ID", "forwardTo": "tel:+13105551234", "recordingEnabled": false}' ``` Recording is off by default. When `recordingEnabled` is true, a consent prompt plays before recording starts, and recording adds a small per-minute surcharge to what Zernio bills (the carrier connection). To change the destination or recording flag later, use `updateWhatsAppCalling` (PATCH) with the same path id. To turn calling off, use `disableWhatsAppCalling` (DELETE) with the `accountId` query param; the forwarding destination is preserved so you can re-enable without reconfiguring. ### Check Call Permission Before placing an outbound call, confirm the consumer has granted call permission. WhatsApp requires the customer to opt in; the response tells you whether you can start a call now or must first request permission. ```typescript const { data } = await zernio.whatsappcalling.getWhatsAppCallPermissions({ query: { accountId: 'YOUR_ACCOUNT_ID', to: '+13105551234' } }); console.log(data.permission.status); // 'permanent' | 'temporary' | 'no_permission' ``` ```python response = client.whatsapp_calling.get_whats_app_call_permissions( account_id='YOUR_ACCOUNT_ID', to='+13105551234', ) print(response['permission']['status']) # 'permanent' | 'temporary' | 'no_permission' ``` ```bash curl "https://zernio.com/api/v1/whatsapp/call-permissions?accountId=YOUR_ACCOUNT_ID&to=%2B13105551234" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Place an Outbound Call Dial a consumer. The call bridges to the number's configured `forwardTo` destination unless you pass an override. Only `accountId` and `to` are required. ```typescript const { data } = await zernio.whatsappcalling.initiateWhatsAppCall({ body: { accountId: 'YOUR_ACCOUNT_ID', to: '+13105551234' } }); console.log(data.callId, data.status); // '...', 'dialing' ``` ```python response = client.whatsapp_calling.initiate_whats_app_call( account_id='YOUR_ACCOUNT_ID', to='+13105551234', ) print(response['callId'], response['status']) # '...', 'dialing' ``` ```bash curl -X POST "https://zernio.com/api/v1/whatsapp/calls" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_ACCOUNT_ID", "to": "+13105551234"}' ``` ### List Call History Fetch past calls for a number, with optional filters by status, direction, and date range. Each call carries its duration, end reason, recording URL (if enabled), and a billing breakdown. ```typescript const { data } = await zernio.whatsappcalling.listWhatsAppCalls({ query: { accountId: 'YOUR_ACCOUNT_ID', direction: 'inbound', limit: 50 } }); // billing.billableCostUSD is what Zernio charges (carrier connection + // recording). Meta's per-minute outbound rate is billed by Meta directly to // your WABA and is reported separately as billing.metaCostUSD (display only). data.calls.forEach(c => console.log(c.direction, c.status, c.durationSeconds, c.billing?.billableCostUSD)); ``` ```python response = client.whatsapp_calling.list_whats_app_calls( account_id='YOUR_ACCOUNT_ID', direction='inbound', limit=50, ) # billing.billableCostUSD is what Zernio charges (carrier connection + # recording). Meta's per-minute outbound rate is billed by Meta directly to # your WABA and is reported separately as billing.metaCostUSD (display only). for c in response['calls']: print(c['direction'], c['status'], c.get('durationSeconds'), c.get('billing', {}).get('billableCostUSD')) ``` ```bash curl "https://zernio.com/api/v1/whatsapp/calls?accountId=YOUR_ACCOUNT_ID&direction=inbound&limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" ``` --- # Connection & Setup Connect a WhatsApp Business Account via redirect flow, headless credentials, or the dashboard import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Connection (API) WhatsApp requires a **WhatsApp Business Account (WABA)** and uses Meta's infrastructure for authentication. There are two ways to connect via the API: ### Option 1: Redirect Flow (Recommended) The standard OAuth-style redirect flow. Works from any domain, no JavaScript SDK required. Same pattern as connecting Instagram, TikTok, or any other platform. 1. Your app calls the connect endpoint to get an auth URL 2. You redirect the user to Meta's WhatsApp signup page 3. User selects or creates a WhatsApp Business Account and picks a phone number 4. User is redirected back to your app with the connection details **Step 1: Get the auth URL** ```typescript const { data } = await zernio.get('/v1/connect/whatsapp', { params: { profileId: 'YOUR_PROFILE_ID', redirect_url: 'https://yourdomain.com/callback' } }); // Redirect your user to the auth URL // data.authUrl -> Meta's WhatsApp signup page ``` ```python response = client.get('/v1/connect/whatsapp', params={ 'profileId': 'YOUR_PROFILE_ID', 'redirect_url': 'https://yourdomain.com/callback' }) auth_url = response.json()['authUrl'] # Redirect user to auth_url ``` ```bash curl "https://zernio.com/api/v1/connect/whatsapp?profileId=YOUR_PROFILE_ID&redirect_url=https://yourdomain.com/callback" \ -H "Authorization: Bearer YOUR_API_KEY" # Returns: { "authUrl": "https://facebook.com/...", "state": "..." } ``` **Step 2:** Redirect your user to the `authUrl`. They complete WhatsApp Business signup on Meta's page. **Step 3:** After completing signup, the user is redirected back to your `redirect_url` with connection details appended as query parameters: ``` https://yourdomain.com/callback?connected=whatsapp&profileId=xxx&accountId=xxx&username=+1234567890 ``` The account is now connected and ready to send and receive messages. This is the same connect flow used by all platforms in Zernio. No Facebook JavaScript SDK is needed. Works from any domain. ### Option 2: Headless Credentials (No Browser) If you already have your Meta credentials, you can connect entirely via API with no browser popup. This is ideal for server-to-server integrations, CLI tools, or automated provisioning. **Prerequisites:** Create a System User in [Meta Business Suite](https://business.facebook.com/settings/system-users), generate a permanent access token with `whatsapp_business_management` and `whatsapp_business_messaging` permissions (add `whatsapp_business_manage_events` too if you plan to use [Click-to-WhatsApp conversion tracking](/platforms/whatsapp/ctwa#conversions-api-for-business-messaging)), and get your WABA ID and Phone Number ID from the WhatsApp Manager. ```typescript const { data } = await zernio.post('/v1/connect/whatsapp/credentials', { profileId: 'YOUR_PROFILE_ID', accessToken: 'YOUR_META_SYSTEM_USER_TOKEN', wabaId: 'YOUR_WABA_ID', phoneNumberId: 'YOUR_PHONE_NUMBER_ID' }); console.log('Connected:', data.account.accountId); ``` ```python response = client.post('/v1/connect/whatsapp/credentials', json={ 'profileId': 'YOUR_PROFILE_ID', 'accessToken': 'YOUR_META_SYSTEM_USER_TOKEN', 'wabaId': 'YOUR_WABA_ID', 'phoneNumberId': 'YOUR_PHONE_NUMBER_ID' }) print(f"Connected: {response.json()['account']['accountId']}") ``` ```bash curl -X POST https://zernio.com/api/v1/connect/whatsapp/credentials \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "accessToken": "YOUR_META_SYSTEM_USER_TOKEN", "wabaId": "YOUR_WABA_ID", "phoneNumberId": "YOUR_PHONE_NUMBER_ID" }' ``` The endpoint validates your credentials against Meta's API, creates the connection, subscribes to webhooks, and registers the phone number on the WhatsApp network. If the `phoneNumberId` is not found in your WABA, the response includes available phone numbers so you can correct it. ### WhatsApp Business App Coexistence Businesses that already use the **WhatsApp Business app** can connect their existing number to Cloud API without giving up the app. During the Embedded Signup flow (Option 1), they will see an option to connect their existing WhatsApp Business app account and phone number. When a business chooses this option: - Their phone number works with **both** the WhatsApp Business app and Cloud API simultaneously - Messages sent and received are **mirrored** between both apps (up to 6 months of history is synced) - Contacts from the WhatsApp Business app are imported automatically - The business can still send individual messages from the WA Business app **Limitations:** | Feature | Status | |---------|--------| | Throughput | Fixed at 20 messages per second | | Group chats (app-side) | Not synced. WA Business app groups are not visible via API | | Groups API | Not supported. To create or manage groups via API (see [Group Chats](/platforms/whatsapp/groups)) you need a non-coexistence number (Cloud API only) | | Voice/video calls | Not supported via API | | Disappearing messages | Turned off for all 1:1 chats | | View once messages | Disabled for all 1:1 chats | | Broadcast lists | Disabled in the WA Business app | **Disconnection:** The business disconnects from the WhatsApp Business app (Settings > Account > Business Platform > Disconnect). This cannot be done via API. When they disconnect, Zernio automatically deactivates the account. Coexistence is transparent to your integration. You use the same API endpoints for sending messages, managing contacts, and everything else. The only difference is the lower throughput limit (20 mps vs. the standard Cloud API throughput). ## Getting Started (App Setup) Follow these steps to connect your WhatsApp Business Account through the Zernio dashboard. ### Step 1: Go to Connections Navigate to **Connections** in the sidebar and find the **WhatsApp** card. Click **+ Connect** to start the setup. ![Go to Connections and click Connect on WhatsApp](/docs-static/whatsapp/1.png) ### Step 2: Choose Your Number Type You'll see two options: 1. **Get a new number** (recommended) - Zernio provisions and verifies a number for you. You pick the country next. US is $2/mo; most other countries run $2 to $25/mo (shown before you confirm). 2. **Use my own number** - Bring your existing phone number. Requires verification during setup. ![Choose between getting a new number or using your own](/docs-static/whatsapp/2.png) ### Step 3: Pick a Country and Confirm Choose the country for your number and review its monthly price. - **US and other instant countries** are provisioned and verified in about 30 seconds. - **Regulated countries** (most of Europe, plus many others) require a one-time identity-verification (KYC) form before the number can be provisioned: the registrant's name, a local address, and an ID document or business registration. Some countries (for example, Australia) also require the end user to complete an ID check via a secure link. After you submit, the order goes to regulatory review and the number activates within 1-3 business days; we email you (and fire the [`whatsapp.number.activated`](/webhooks#whatsappnumberactivated) webhook) when it's ready. Click **Confirm** to proceed. ![Pick a country and confirm your phone number purchase](/docs-static/whatsapp/3.png) ### Step 4: Auto-Verification Zernio automatically verifies your number with Meta. This usually takes about 30 seconds - just wait for the verification to complete. ![Auto-verifying your number with Meta](/docs-static/whatsapp/4.png) ### Step 5: Continue to Meta Registration Once verification is complete, click **Continue to WhatsApp setup** to register your phone number with Meta. ![Click continue to register with Meta](/docs-static/whatsapp/5.png) ### Step 6: Meta Account Setup You'll be redirected to Meta's Embedded Signup. Here you can either create a new WhatsApp Business Account or connect to an existing one. Follow the prompts to complete the setup. ![Meta Embedded Signup - create or choose account](/docs-static/whatsapp/6.png) Planning to use the [Groups API](/platforms/whatsapp/groups)? In this step, do **not** choose "Connect existing WhatsApp Business app account". That option activates [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence), which disables group create and management endpoints. Create a new WABA or select a number that is not already in the WhatsApp Business app. ### Step 7: Select Your Number In the phone number selection, you'll see the number you purchased already marked as **Verified**. Select it and continue. ![Your purchased number appears as verified](/docs-static/whatsapp/7.png) ### Step 8: Connected! Once verified, your WhatsApp account appears in your connections list with a **connected** status. You can now click **Settings** to configure it. ![WhatsApp connected successfully](/docs-static/whatsapp/8.png) ### Step 9: Manage Templates Click **Settings** on your WhatsApp connection to view and create message templates. Templates must be approved by Meta before use. ![View and manage your WhatsApp templates](/docs-static/whatsapp/9.png) ### Step 10: Configure Business Profile Switch to the **Business Profile** tab to customize your profile picture, display name, about text, description, and contact information. ![Configure your WhatsApp Business Profile](/docs-static/whatsapp/10.png) After connecting, you'll start in Meta's **sandbox tier** with a limit of 250 unique contacts per day. This limit increases automatically as you build messaging history and maintain quality. --- # Contacts & Profile Manage your WhatsApp contact list and business profile import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Contacts Manage your WhatsApp contact list for broadcasts and messaging. Store phone numbers, tags, opt-in status, and custom fields for each contact. ### Create a Contact Create a WhatsApp contact with their phone number. The phone number links the contact to your WABA for messaging: ```typescript const { data } = await zernio.contacts.createContact({ body: { profileId: 'YOUR_PROFILE_ID', name: 'John Doe', email: 'john@example.com', tags: ['vip', 'newsletter'], // Link this contact to your WhatsApp number accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', platform: 'whatsapp', platformIdentifier: '+1234567890' } }); console.log('Contact created:', data.contact.id); ``` ```python response = client.contacts.create_contact( profile_id='YOUR_PROFILE_ID', name='John Doe', email='john@example.com', tags=['vip', 'newsletter'], # Link this contact to your WhatsApp number account_id='YOUR_WHATSAPP_ACCOUNT_ID', platform='whatsapp', platform_identifier='+1234567890' ) print(f"Contact created: {response.contact.id}") ``` ```bash curl -X POST https://zernio.com/api/v1/contacts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "name": "John Doe", "email": "john@example.com", "tags": ["vip", "newsletter"], "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "platform": "whatsapp", "platformIdentifier": "+1234567890" }' ``` ### Bulk Import Import up to 1,000 WhatsApp contacts at once with phone numbers and tags. Duplicates are automatically skipped: ```typescript const { data } = await zernio.contacts.bulkCreateContacts({ body: { profileId: 'YOUR_PROFILE_ID', accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', platform: 'whatsapp', contacts: [ { name: 'John Doe', platformIdentifier: '+1234567890', tags: ['vip'] }, { name: 'Jane Smith', platformIdentifier: '+0987654321', tags: ['newsletter'] } ] } }); console.log(`Created: ${data.created}, Skipped: ${data.skipped}`); ``` ```python response = client.contacts.bulk_create_contacts( profile_id='YOUR_PROFILE_ID', account_id='YOUR_WHATSAPP_ACCOUNT_ID', platform='whatsapp', contacts=[ {'name': 'John Doe', 'platform_identifier': '+1234567890', 'tags': ['vip']}, {'name': 'Jane Smith', 'platform_identifier': '+0987654321', 'tags': ['newsletter']} ] ) print(f"Created: {response.created}, Skipped: {response.skipped}") ``` ```bash curl -X POST https://zernio.com/api/v1/contacts/bulk \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "YOUR_PROFILE_ID", "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "platform": "whatsapp", "contacts": [ {"name": "John Doe", "platformIdentifier": "+1234567890", "tags": ["vip"]}, {"name": "Jane Smith", "platformIdentifier": "+0987654321", "tags": ["newsletter"]} ] }' ``` ### Update a Contact Update a WhatsApp contact's tags, opt-in status, or other fields: ```typescript const { data } = await zernio.contacts.updateContact({ path: { contactId: 'CONTACT_ID' }, body: { tags: ['vip', 'promo-march'], isSubscribed: true } }); console.log('Updated:', data.contact.id); ``` ```python response = client.contacts.update_contact( contact_id='CONTACT_ID', tags=['vip', 'promo-march'], is_subscribed=True ) print(f"Updated: {response.contact.id}") ``` ```bash curl -X PATCH "https://zernio.com/api/v1/contacts/CONTACT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tags": ["vip", "promo-march"], "isSubscribed": true}' ``` ## Business Profile ```typescript // Get business profile const { data } = await zernio.whatsapp.getWhatsAppBusinessProfile({ query: { accountId: 'YOUR_ACCOUNT_ID' } }); console.log(data.businessProfile); // Update business profile await zernio.whatsapp.updateWhatsAppBusinessProfile({ body: { accountId: 'YOUR_ACCOUNT_ID', about: 'Your go-to store for widgets', description: 'We sell the best widgets in town.', email: 'hello@example.com', websites: ['https://example.com'] } }); ``` ```python # Get business profile response = client.whatsapp.get_whats_app_business_profile( account_id='YOUR_ACCOUNT_ID' ) print(response.business_profile) # Update business profile client.whatsapp.update_whats_app_business_profile( account_id='YOUR_ACCOUNT_ID', about='Your go-to store for widgets', description='We sell the best widgets in town.', email='hello@example.com', websites=['https://example.com'] ) ``` ```bash # Get business profile curl "https://zernio.com/api/v1/whatsapp/business-profile?accountId=YOUR_ACCOUNT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" # Update business profile curl -X POST https://zernio.com/api/v1/whatsapp/business-profile \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_ACCOUNT_ID", "about": "Your go-to store for widgets", "description": "We sell the best widgets in town.", "email": "hello@example.com", "websites": ["https://example.com"] }' ``` --- # Click-to-WhatsApp Ads Capture CTWA clicks and forward conversion events to Meta for attribution import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Click-to-WhatsApp ads & attribution Click-to-WhatsApp (CTWA) ads are Meta ads that, when tapped, open a WhatsApp conversation with your business instead of a website. They're a Meta Ads concept, Zernio creates them via [`POST /v1/ads/ctwa`](/platforms/meta-ads/ctwa#click-to-whatsapp-ads) (see the [Meta Ads page](/platforms/meta-ads/ctwa#click-to-whatsapp-ads) for the request shape and prerequisites). This page covers the WhatsApp side of the loop: capturing the originating click and sending conversion events back to Meta for attribution. ### Automatic ctwa_clid capture When a user reaches your WhatsApp business via a CTWA ad, Meta attaches a `referral` object, including `ctwa_clid` (Meta's click ID for WhatsApp), to the **first inbound message** of the conversation. Zernio's webhook handler captures it automatically and persists it on the [Conversation](/messages/list-inbox-conversations) record's `metadata`: ```json { "metadata": { "ctwa_clid": "AbCdEfGhIjKlMn0pQrStUvWxYz", "ctwa_captured_at": "2026-04-27T09:18:44.991Z", "ctwa_source_id": "120000000000000000", "ctwa_source_url": "https://fb.me/...", "ctwa_headline": "Chat with us on WhatsApp", "ctwa_source_type": "ad" } } ``` The capture is one-shot, once `ctwa_clid` is set, later messages from the same user never overwrite it (Meta only emits `referral` on the first message after a click). ### Conversions API for Business Messaging Forward conversion events that happen inside the WhatsApp thread back to Meta with `action_source = business_messaging` so they attribute to the originating CTWA ad. Distinct from Meta Ads' general [Conversions API](/platforms/meta-ads/capi#conversions-api): that one is for web pixel events; this one is purpose-built for messaging conversions. #### Provision the dataset Before sending events you need a Meta dataset linked to the WABA. Zernio creates one for you in a single call. The endpoint is GET-first idempotent (a WABA can only own one CTWA dataset), so re-running it is a safe no-op that returns the existing ID with `created: false`. ```typescript const { data } = await zernio.whatsapp.createWhatsAppDataset({ body: { accountId: 'WHATSAPP_ACCOUNT_ID' } }); console.log(data.datasetId, data.created); // created: false if one already existed ``` ```python response = client.whatsapp.create_whats_app_dataset( account_id='WHATSAPP_ACCOUNT_ID' ) print(response.dataset_id, response.created) # created: False if one already existed ``` ```bash curl -X POST "https://zernio.com/api/v1/whatsapp/dataset" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "WHATSAPP_ACCOUNT_ID"}' ``` The dataset ID is persisted on the WhatsApp account as `metadata.metaCapiDatasetId`, which is what [`POST /v1/whatsapp/conversions`](/platforms/whatsapp/ctwa#send-a-conversion-event) reads to attribute events. There is also a `GET /v1/whatsapp/dataset?accountId=` that returns the persisted ID without touching Meta, useful for surfacing whether tracking is set up on an account. If Meta rejects the call with `(#100) Invalid parameter`, the most common cause is that the connected WhatsApp Business Account is not fully verified yet. Complete business verification in Meta Business Manager and try again. The endpoint returns `422` and includes Meta's raw error so other causes (region restrictions, app-level disablement) are visible. If you connect via [Headless Credentials](/platforms/whatsapp/connection#option-2-headless-credentials-no-browser), make sure your System User token includes `whatsapp_business_manage_events`, the scope is auto-extended to tokens minted through the [Redirect Flow](/platforms/whatsapp/connection#option-1-redirect-flow-recommended) but not to System User tokens you mint yourself. Prefer to use an existing dataset (for example, one you already own in a different Business Manager)? Skip the provisioning call and set `metadata.metaCapiDatasetId` directly. In that case the WhatsApp account's access token must have access to that dataset, 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). Share the dataset with the WhatsApp app's Business in Meta Business Manager, or use one already in the same Business. #### Send a conversion event ```typescript const result = await zernio.whatsapp.sendWhatsAppConversion({ body: { accountId: 'WHATSAPP_ACCOUNT_ID', conversationId: 'CONVERSATION_ID', // or use phoneE164 eventName: 'LeadSubmitted', eventId: 'lead_abc_123', value: 49.00, currency: 'USD', }}); ``` ```python result = client.whatsapp.send_whats_app_conversion( account_id="WHATSAPP_ACCOUNT_ID", conversation_id="CONVERSATION_ID", event_name="LeadSubmitted", event_id="lead_abc_123", value=49.00, currency="USD", ) ``` ```bash curl -X POST "https://zernio.com/api/v1/whatsapp/conversions" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "WHATSAPP_ACCOUNT_ID", "conversationId": "CONVERSATION_ID", "eventName": "LeadSubmitted", "eventId": "lead_abc_123", "value": 49.00, "currency": "USD" }' ``` #### Check recent activity List the most recent conversion events sent for a WhatsApp account, useful if you want to mirror the "Conversions" tab inside your own dashboard or build alerting on top of attribution sends. ```typescript const { data } = await zernio.whatsapp.listWhatsAppConversions({ query: { accountId: 'WHATSAPP_ACCOUNT_ID', limit: 50 } }); data.events.forEach(e => { console.log(e.timestamp, e.eventName, e.conversationId, e.eventsReceived, e.eventsFailed); }); ``` ```python response = client.whatsapp.list_whats_app_conversions( account_id='WHATSAPP_ACCOUNT_ID', limit=50 ) for e in response.events: print(e.timestamp, e.event_name, e.conversation_id, e.events_received, e.events_failed) ``` ```bash curl "https://zernio.com/api/v1/whatsapp/conversions?accountId=WHATSAPP_ACCOUNT_ID&limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Each row carries the event timestamp, the event name (one of the [supported event names](/platforms/whatsapp/ctwa#supported-event-names)), the originating Zernio `conversationId`, `eventsReceived` / `eventsFailed` as Meta reported them on the send, the Meta `traceId` (`fbtrace_id`, handy for cross-referencing in Meta Events Manager), and how long the send took. The feed is sourced from delivery logs, not a persisted event store, so the window is bounded by log retention (about 30 days). If you need a long-lived audit trail, persist on your side as you call `POST /v1/whatsapp/conversions`. ### Supported event names Meta's allowlist for `business_messaging` events is narrower than the standard pixel CAPI: | Event | Use for | |---|---| | `LeadSubmitted` | Form filled, contact details captured, lead qualified | | `Purchase` | Customer completed a purchase (payment confirmed, invoice issued) | | `AddToCart` | Customer added an item to cart in-thread | | `InitiateCheckout` | Customer started a checkout flow | | `ViewContent` | Customer viewed a specific product / service | `Lead` (the standard pixel name) is **not** accepted on `business_messaging` events. Use `LeadSubmitted` instead. Other standard pixel names (`CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`, `StartTrial`, `AddPaymentInfo`, `Search`, `SubmitApplication`) are also rejected, these constraints are live-verified against Graph API v25.0 and enforced at Zernio's request boundary. ### Attribution window & test mode Meta's attribution window is **7 days from click**. If the customer's `ctwa_clid` was captured more than 7 days ago, the event still posts but won't attribute. Pass `testCode: "TEST12345"` at the request root to route events to the Test Events tab in Meta Events Manager without affecting production dataset data. ### Resolving the conversation You can identify the originating conversation either way: - `conversationId`, preferred. The Zernio Conversation `_id` returned by [list conversations](/messages/list-inbox-conversations). - `phoneE164`, fallback. Digits only, no `+`. The handler picks the most recent CTWA-attributed conversation for that phone on the supplied account. If the resolved conversation has no captured `ctwa_clid`, the request returns `422`, there's nothing to attribute (the conversation didn't originate from a CTWA ad, or pre-dates Zernio's capture). --- # Flows Build interactive WhatsApp Flows: forms, surveys, and booking experiences import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; ## Flows WhatsApp Flows let you build native interactive forms, surveys, and booking experiences inside WhatsApp. Flows are created in DRAFT status, populated with a Flow JSON definition, then published for sending. Published flows are immutable -- to update, create a new flow (optionally cloning the old one). ### Create and Publish a Flow ```typescript // Step 1: Create a flow const { data } = await zernio.whatsappflows.createWhatsAppFlow({ body: { accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', name: 'lead_capture_form', categories: ['LEAD_GENERATION'] } }); const flowId = data.flow.id; // Step 2: Upload the Flow JSON await zernio.whatsappflows.uploadWhatsAppFlowJson({ path: { flowId }, body: { accountId: 'YOUR_WHATSAPP_ACCOUNT_ID', flow_json: { version: '6.0', screens: [{ id: 'LEAD_FORM', title: 'Get a Quote', terminal: true, success: true, layout: { type: 'SingleColumnLayout', children: [ { type: 'TextInput', name: 'full_name', label: 'Full Name', required: true, 'input-type': 'text' }, { type: 'TextInput', name: 'email', label: 'Email', required: true, 'input-type': 'email' }, { type: 'Footer', label: 'Submit', 'on-click-action': { name: 'complete', payload: { full_name: '${form.full_name}', email: '${form.email}' } } } ] } }] } } }); // Step 3: Publish (irreversible) await zernio.whatsappflows.publishWhatsAppFlow({ path: { flowId }, body: { accountId: 'YOUR_WHATSAPP_ACCOUNT_ID' } }); ``` ```python # Step 1: Create a flow response = client.whatsapp_flows.create_whats_app_flow( account_id='YOUR_WHATSAPP_ACCOUNT_ID', name='lead_capture_form', categories=['LEAD_GENERATION'] ) flow_id = response.flow.id # Step 2: Upload the Flow JSON client.whatsapp_flows.upload_whats_app_flow_json( flow_id=flow_id, account_id='YOUR_WHATSAPP_ACCOUNT_ID', flow_json={ 'version': '6.0', 'screens': [{ 'id': 'LEAD_FORM', 'title': 'Get a Quote', 'terminal': True, 'success': True, 'layout': { 'type': 'SingleColumnLayout', 'children': [ {'type': 'TextInput', 'name': 'full_name', 'label': 'Full Name', 'required': True, 'input-type': 'text'}, {'type': 'TextInput', 'name': 'email', 'label': 'Email', 'required': True, 'input-type': 'email'}, {'type': 'Footer', 'label': 'Submit', 'on-click-action': {'name': 'complete', 'payload': {'full_name': '${form.full_name}', 'email': '${form.email}'}}} ] } }] } ) # Step 3: Publish (irreversible) client.whatsapp_flows.publish_whats_app_flow( flow_id=flow_id, account_id='YOUR_WHATSAPP_ACCOUNT_ID' ) ``` ```bash # Step 1: Create a flow FLOW_ID=$(curl -s -X POST https://zernio.com/api/v1/whatsapp/flows \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "name": "lead_capture_form", "categories": ["LEAD_GENERATION"] }' | jq -r '.flow.id') # Step 2: Upload the Flow JSON curl -X PUT "https://zernio.com/api/v1/whatsapp/flows/$FLOW_ID/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "YOUR_WHATSAPP_ACCOUNT_ID", "flow_json": { "version": "6.0", "screens": [{ "id": "LEAD_FORM", "title": "Get a Quote", "terminal": true, "success": true, "layout": { "type": "SingleColumnLayout", "children": [ {"type": "TextInput", "name": "full_name", "label": "Full Name", "required": true, "input-type": "text"}, {"type": "TextInput", "name": "email", "label": "Email", "required": true, "input-type": "email"}, {"type": "Footer", "label": "Submit", "on-click-action": {"name": "complete", "payload": {"full_name": "${form.full_name}", "email": "${form.email}"}}} ] } }] } }' # Step 3: Publish (irreversible) curl -X POST "https://zernio.com/api/v1/whatsapp/flows/$FLOW_ID/publish" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"accountId": "YOUR_WHATSAPP_ACCOUNT_ID"}' ``` ### Preview a Flow Get a public web-preview URL that renders the flow (drafts included) so you can visualize it before publishing. The link works without login and can be embedded as an iframe or shared with stakeholders. It is reused across calls (valid ~30 days); pass `invalidate: true` to mint a fresh one (the previous link stops working). ```typescript const { data } = await zernio.whatsappflows.getWhatsAppFlowPreview({ path: { flowId }, query: { accountId: 'YOUR_WHATSAPP_ACCOUNT_ID' } }); console.log(data.preview_url); // embed as