# Pozy documentation (full text) All guides from https://pozy.ai/docs in one file. API reference: https://pozy.ai/docs/md/api (index) and https://pozy.ai/openapi.json (spec). # Pozy documentation One API to publish, message, advertise and measure across every major platform. Start with the quickstart, or jump to the part you need. Pozy gives you one REST API, one dashboard and one AI agent surface for the platforms your users already live on. You connect an account once, then post to it, read its analytics, answer its DMs and run its ads with the same request shape everywhere. Everything in the dashboard runs on the same public API you call. If you can do it in the app, you can do it with a request. ## Start here - [Quickstart](https://pozy.ai/docs/getting-started/quickstart): Create a key, connect an account, publish your first post and receive your first webhook. About ten minutes. - [Core concepts](https://pozy.ai/docs/getting-started/concepts): Workspaces, profiles, accounts and posts, and how they fit together. - [Authentication](https://pozy.ai/docs/getting-started/authentication): API keys, read-only keys, profile scopes and disabled resource groups. - [API reference](https://pozy.ai/docs/api): Every endpoint, with parameters, schemas and examples in cURL, Node and Python. ## What you can build - [Publishing](https://pozy.ai/docs/publishing): Schedule posts, threads, stories, reels and videos, with queues, drafts and approvals. - [Inbox](https://pozy.ai/docs/inbox): Read and answer DMs, comments, reviews and mentions from one place. - [Automations](https://pozy.ai/docs/automations): Comment-to-DM, workflows, sequences and broadcasts. - [Analytics](https://pozy.ai/docs/analytics): Reach, engagement, followers, best time to post and content decay. - [Ads](https://pozy.ai/docs/ads): Create, boost and measure campaigns on nine ad networks. - [WhatsApp](https://pozy.ai/docs/whatsapp): Templates, flows, broadcasts, calling and a business agent. - [Phone, SMS & voice](https://pozy.ai/docs/telephony): Buy numbers, send texts, place calls and run AI voice agents. - [Build a platform](https://pozy.ai/docs/build): Give every one of your customers their own profile, keys and webhooks. ## Ways to use Pozy | Surface | Best for | Start | | --- | --- | --- | | REST API | Your own product, backend jobs, anything custom | [Quickstart](https://pozy.ai/docs/getting-started/quickstart) | | Node.js | TypeScript and JavaScript apps, with the built-in `fetch` | [Node.js](https://pozy.ai/docs/sdks/node) | | curl | Scripts, CI and quick checks from a terminal | [From the terminal](https://pozy.ai/docs/cli) | | MCP server | Claude, ChatGPT, Cursor and other AI assistants | [MCP server](https://pozy.ai/docs/mcp) | | No-code tools | n8n, Make and Zapier | [Integrations](https://pozy.ai/docs/integrations) | ## Base URL All requests go to `https://api.pozy.ai/v1` and authenticate with a bearer key: ```bash cURL curl https://api.pozy.ai/v1/accounts \ -H "Authorization: Bearer pz_live_..." ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.accounts); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/accounts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, ) print(resp.json()["accounts"]) ``` > **Using an AI assistant?:** > > Every page has a **Copy page** button that copies it as Markdown. The whole site is also available as plain text at [/llms.txt](https://pozy.ai/llms.txt) and [/llms-full.txt](https://pozy.ai/llms-full.txt). ## Supported platforms | Platform | Publish | Analytics | DMs | Comments | Reviews | Connect | | --- | --- | --- | --- | --- | --- | --- | | Instagram | Yes | Yes | Yes | Yes | – | OAuth | | TikTok | Yes | Yes | Yes | Yes | – | OAuth | | YouTube | Yes | Yes | – | Yes | – | OAuth | | Facebook | Yes | Yes | Yes | Yes | Yes | OAuth | | LinkedIn | Yes | Yes | – | Yes | – | OAuth | | X | Yes | Yes | Yes | Yes | – | OAuth | | Threads | Yes | Yes | – | Yes | – | OAuth | | Pinterest | Yes | Yes | – | – | – | OAuth | | Bluesky | Yes | Yes | Yes | Yes | – | Credentials | | Google Business Profile | Yes | Yes | – | – | Yes | OAuth | | Snapchat (beta) | Yes | Yes | – | – | – | OAuth | | Reddit (paused) | Yes | Yes | Yes | Yes | – | OAuth | | WhatsApp | Yes | – | Yes | – | – | Credentials | | Telegram (coming soon) | Yes | – | Yes | – | – | Bot token | | Discord (coming soon) | Yes | – | – | – | – | Bot token | | Slack | Yes | – | Yes | – | – | Bot token | | iMessage | – | – | Yes | – | – | Phone number | | SMS | – | – | Yes | – | – | Phone number | | RCS | – | – | Yes | – | – | Phone number | | Meta Ads | – | – | – | – | – | OAuth | | Google Ads | – | – | – | – | – | OAuth | | TikTok Ads | – | – | – | – | – | OAuth | | LinkedIn Ads | – | – | – | – | – | OAuth | | Pinterest Ads | – | – | – | – | – | OAuth | | X Ads | – | – | – | – | – | OAuth | | ChatGPT Ads | – | – | – | – | – | API token | | Reddit Ads | – | – | – | – | – | OAuth | | Whop Ads | – | – | – | – | – | API token | | Shopify | – | – | – | – | – | OAuth | | WooCommerce | – | – | – | – | – | Credentials | | WordPress | – | – | – | – | – | OAuth | | Wix | – | – | – | – | – | OAuth | | Webflow | – | – | – | – | – | API token | | HubSpot | – | – | – | – | – | OAuth | | Google Analytics | – | – | – | – | – | OAuth | | Google Workspace | – | – | – | – | – | OAuth | | Microsoft 365 | – | – | – | – | – | OAuth | See [Platforms](https://pozy.ai/docs/platforms) for what each one supports, its media limits and how to connect it. --- # Quickstart Create an API key, connect an account, publish and schedule a post, and receive your first signed webhook in about ten minutes. This page takes you from an empty workspace to a published post and a verified webhook. You need a Pozy account and a login for one social platform. The examples use LinkedIn, but every OAuth platform works the same way. Your first two connected accounts are free and need no card. X is the exception: it bills every API call, so connecting an X account asks for a card first ([pricing](https://pozy.ai/docs/billing)). ### Create an API key Open [API keys](https://pozy.ai/dashboard/api-keys) in the dashboard and click **Create key**. Give it a name you will recognise later, such as `quickstart`, and keep the defaults (full access, read and write). Copy the key straight away. Pozy stores only a hash of it, so it is shown once. Keys start with `pz_live_`. Put it in an environment variable. Every example below reads it from there. ```bash Terminal export POZY_API_KEY="pz_live_..." ``` The Node examples use the `fetch` built into Node.js 18 and later, and the Python examples use `requests`. There is nothing Pozy-specific to install. ### Check the key `GET /v1/auth/verify` tells you whether a credential works and what it can reach. It is the cheapest call to make when something looks wrong. ```bash cURL curl https://api.pozy.ai/v1/auth/verify \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/auth/verify", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/auth/verify", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, ) print(resp.json()) ``` ```json { "valid": true, "userId": "usr_4fQ9kT2mZx8LwB1nR7cY", "name": "Alex Rivera", "email": "alex@example.com", "authType": "api_key", "scope": "full" } ``` A `401` with `"code": "invalid_api_key"` means the key was mistyped, revoked or expired. ### Create a profile A profile is a container for connected accounts: one per brand, client or customer of yours. Create one for this walkthrough and keep its `_id`. ```bash cURL curl -X POST https://api.pozy.ai/v1/profiles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Quickstart" }' ``` ```ts Node const createdRes = await fetch("https://api.pozy.ai/v1/profiles", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Quickstart" }), }); const created = await createdRes.json(); if (!createdRes.ok) throw new Error(created.error); const profileId = created.profile._id; ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/profiles", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"name": "Quickstart"}, ) profile_id = resp.json()["profile"]["_id"] ``` ```json { "message": "Profile created successfully", "profile": { "_id": "6720f3b1c9e4a85d2f0b7c14", "name": "Quickstart", "externalId": null, "isDefault": false, "accountCount": 0 } } ``` ### Connect an account Ask for a connect URL for the platform and the profile. Open the `authUrl` in a browser, sign in to LinkedIn and approve. ```bash cURL curl "https://api.pozy.ai/v1/connect/linkedin?profileId=6720f3b1c9e4a85d2f0b7c14" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const connectRes = await fetch(`https://api.pozy.ai/v1/connect/linkedin?${new URLSearchParams({ profileId })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const connect = await connectRes.json(); if (!connectRes.ok) throw new Error(connect.error); console.log("Open this URL and approve:", connect.authUrl); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/linkedin", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": profile_id}, ) print("Open this URL and approve:", resp.json()["authUrl"]) ``` Pozy hosts every screen of the flow, including the page that asks whether to connect your personal LinkedIn profile or a company page. When you pass a `redirect_url`, the browser comes back to it with the new account's `accountId` in the query string. Without one, the flow ends on a Pozy page that says the account is connected. [Connecting accounts](https://pozy.ai/docs/connect) covers branded connect pages, headless flows for your own UI and the platforms that use credentials instead of OAuth. ### Find the account id Posts target accounts by their `_id`, never by username. List the accounts in the profile: ```bash cURL curl "https://api.pozy.ai/v1/accounts?profileId=6720f3b1c9e4a85d2f0b7c14" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const listedRes = await fetch(`https://api.pozy.ai/v1/accounts?${new URLSearchParams({ profileId })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const listed = await listedRes.json(); if (!listedRes.ok) throw new Error(listed.error); const accountId = listed.accounts[0]._id; ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/accounts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": profile_id}, ) account_id = resp.json()["accounts"][0]["_id"] ``` ```json { "accounts": [ { "_id": "6720f41a8d3be96c1e5a02f7", "platform": "linkedin", "profileId": "6720f3b1c9e4a85d2f0b7c14", "username": "alex-rivera", "displayName": "Alex Rivera", "isActive": true } ], "hasAnalyticsAccess": true } ``` ### Publish a post now Send the text, the target account and `publishNow: true`. The `Idempotency-Key` header makes the call safe to retry: if the network drops and you send it again with the same key, you get the same post back instead of a second one. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-first-post" \ -d '{ "content": "Hello from the Pozy API.", "platforms": [{ "platform": "linkedin", "accountId": "6720f41a8d3be96c1e5a02f7" }], "publishNow": true }' ``` ```ts Node const publishedRes = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "quickstart-first-post", "Content-Type": "application/json", }, body: JSON.stringify({ content: "Hello from the Pozy API.", platforms: [{ platform: "linkedin", accountId }], publishNow: true, }), }); const published = await publishedRes.json(); if (!publishedRes.ok) throw new Error(published.error); console.log(published.post.status, published.post.platforms[0].platformPostUrl); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "quickstart-first-post", }, json={ "content": "Hello from the Pozy API.", "platforms": [{"platform": "linkedin", "accountId": account_id}], "publishNow": True, }, ) post = resp.json()["post"] print(post["status"], post["platforms"][0].get("platformPostUrl")) ``` The post comes back with an overall `status` and one entry per target in `platforms`, each with its own `status`, `platformPostId` and `platformPostUrl` once it is live. ### Schedule a post Leave out `publishNow` and send `scheduledFor` instead. Pair it with `timezone` so the time is read the way you wrote it: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-scheduled-post" \ -d '{ "content": "This one goes out tomorrow morning.", "platforms": [{ "platform": "linkedin", "accountId": "6720f41a8d3be96c1e5a02f7" }], "scheduledFor": "2026-11-02T09:00:00", "timezone": "Europe/Madrid" }' ``` ```ts Node const scheduledRes = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "quickstart-scheduled-post", "Content-Type": "application/json", }, body: JSON.stringify({ content: "This one goes out tomorrow morning.", platforms: [{ platform: "linkedin", accountId }], scheduledFor: "2026-11-02T09:00:00", timezone: "Europe/Madrid", }), }); const scheduled = await scheduledRes.json(); if (!scheduledRes.ok) throw new Error(scheduled.error); console.log(scheduled.post._id, scheduled.post.status); // "scheduled" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "quickstart-scheduled-post", }, json={ "content": "This one goes out tomorrow morning.", "platforms": [{"platform": "linkedin", "accountId": account_id}], "scheduledFor": "2026-11-02T09:00:00", "timezone": "Europe/Madrid", }, ) print(resp.json()["post"]["status"]) # "scheduled" ``` Until it publishes, you can change or cancel it with `PUT /v1/posts/{postId}` and `DELETE /v1/posts/{postId}`. [Scheduling and timezones](https://pozy.ai/docs/publishing/scheduling) has the details. ### Receive a webhook Instead of polling the post, let Pozy tell you when it goes live. You need a public HTTPS URL. For local testing, run a tunnel to your machine and use its URL. This small Node server accepts deliveries and checks the signature: ```ts import { createServer } from "node:http"; import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.POZY_WEBHOOK_SECRET; createServer((req, res) => { let raw = ""; req.on("data", (chunk) => (raw += chunk)); req.on("end", () => { const expected = createHmac("sha256", SECRET).update(raw).digest("hex"); const got = req.headers["x-pozy-signature"] ?? ""; const ok = got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected)); if (!ok) { res.writeHead(401).end(); return; } const event = JSON.parse(raw); console.log(req.headers["x-pozy-event"], event.id); res.writeHead(200).end(); // answer within 5 seconds }); }).listen(3000); ``` Pick a signing secret, start the server with it, then register the endpoint for the post events: ```bash cURL export POZY_WEBHOOK_SECRET="$(openssl rand -hex 32)" curl -X POST https://api.pozy.ai/v1/webhooks/settings \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Quickstart", "url": "https://your-tunnel.example.com/", "secret": "'"$POZY_WEBHOOK_SECRET"'", "events": ["post.published", "post.failed", "post.partial"] }' ``` ```ts Node const hookRes = await fetch("https://api.pozy.ai/v1/webhooks/settings", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Quickstart", url: "https://your-tunnel.example.com/", secret: process.env.POZY_WEBHOOK_SECRET, events: ["post.published", "post.failed", "post.partial"], }), }); const hook = await hookRes.json(); if (!hookRes.ok) throw new Error(hook.error); console.log(hook.webhook._id); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/webhooks/settings", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "Quickstart", "url": "https://your-tunnel.example.com/", "secret": os.environ["POZY_WEBHOOK_SECRET"], "events": ["post.published", "post.failed", "post.partial"], }, ) print(resp.json()["webhook"]["_id"]) ``` Send a test delivery to check the wiring before the real post goes out: ```bash Terminal curl -X POST https://api.pozy.ai/v1/webhooks/test \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webhookId": "whk_8Jq2LmT4vX9cR1sZp0Ya3Kd", "event": "post.published" }' ``` When the scheduled post publishes, your server prints `post.published` and the event id. The body looks like this: ```json { "id": "evt_8a1f4c2e9b7d3a60", "event": "post.published", "timestamp": "2026-11-02T08:00:03.412Z", "profiles": [{ "id": "6720f3b1c9e4a85d2f0b7c14", "externalId": null }], "post": { "id": "6721a0c47b2e5f9d3a18c6e0", "content": "This one goes out tomorrow morning.", "status": "published", "scheduledFor": "2026-11-02T08:00:00.000Z", "publishedAt": "2026-11-02T08:00:03.120Z", "platforms": [ { "platform": "linkedin", "status": "published", "accountId": "6720f41a8d3be96c1e5a02f7", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7255..." } ] } } ``` > **Always set a secret:** > > Endpoints created without a `secret` receive unsigned deliveries. Set one and reject any request whose `X-Pozy-Signature` does not match. [Verify signatures](https://pozy.ai/docs/webhooks/signatures) shows the check in Node and Python. ## Where to go next - [Core concepts](https://pozy.ai/docs/getting-started/concepts): How workspaces, profiles, accounts and posts relate. - [Create a post](https://pozy.ai/docs/publishing): Media, per-platform text, threads, drafts and approvals. - [Build a platform](https://pozy.ai/docs/build): One profile per customer, scoped keys and webhook routing. - [Errors](https://pozy.ai/docs/getting-started/errors): The error envelope and what to retry. --- # Core concepts How workspaces, members, profiles, connected accounts, posts, media, webhooks and API keys fit together in Pozy. Pozy has a small object model. Learn these eight nouns and every endpoint in the [API reference](https://pozy.ai/docs/api) reads naturally. ## The model at a glance ```text Workspace (billing, rate limits, members, API keys, webhooks) ├── Members owner · admin · member · viewer · billing ├── API keys full or profile-scoped, read-write or read ├── Webhook endpoints events, optional profile and account filters └── Profiles one per brand, client or customer (externalId) └── Connected accounts instagram, linkedin, whatsapp, metaads ... ├── Posts one post, many per-platform targets ├── Conversations DMs, comments, reviews, mentions └── Ads, numbers, analytics ... ``` | Object | What it is | Id field | Where you meet it | | --- | --- | --- | --- | | Workspace | Your team in Pozy. Owns billing, keys, webhooks and every profile. | none in requests (implied by the key) | Dashboard, `GET /v1/auth/verify` | | Member | A person in the workspace with a role. | `userId` | `GET /v1/users` | | Profile | A group of connected accounts for one brand or customer. | `profileId` | `/v1/profiles` | | Connected account | One login on one platform, such as an Instagram business account or a WhatsApp number. | `accountId` | `/v1/accounts` | | Post | Content you publish, with one target per account. | `postId` | `/v1/posts` | | Media item | An image, video, GIF or document attached to a post. | `url` | `mediaItems`, `POST /v1/media/presign` | | Webhook endpoint | A URL Pozy calls when events happen. | `webhookId` | `/v1/webhooks/settings` | | API key | A secret that authenticates requests. | `id` | `/v1/api-keys` | ## Workspace The workspace is the top of the tree. Everything you create belongs to exactly one workspace, and an API key can never reach another workspace's data. Billing is per workspace: connected accounts are counted across all of its profiles ([pricing](https://pozy.ai/docs/billing)). Rate limits are per workspace too, shared by every key ([rate limits](https://pozy.ai/docs/getting-started/rate-limits)). ## Members and roles People join a workspace by invitation and get one role: | Role | Can do | | --- | --- | | `owner` | Everything, including billing and deleting the workspace. | | `admin` | Everything except owning billing and deleting the workspace. | | `member` | Publish, reply, run ads and automations. No API keys, team, webhooks or billing. | | `viewer` | Read only. | | `billing` | Billing and usage only. | Roles apply to dashboard sessions and to OAuth tokens issued to the MCP server. API keys carry their own permissions instead ([authentication](https://pozy.ai/docs/getting-started/authentication)). See [Roles and permissions](https://pozy.ai/docs/security/roles) for the full matrix. ## Profiles A profile groups connected accounts. Use one per brand you manage, or one per customer if you build a product on Pozy. A profile can hold several accounts on the same platform, such as three Facebook Pages. Each profile can carry an `externalId`, your own id for it (a customer id, a tenant slug). Every webhook names the profiles it concerns along with their `externalId`, so you can route events without a lookup ([route webhooks](https://pozy.ai/docs/build/route-webhooks)). Profiles are free. Only connected accounts are billed. Every workspace has a default profile, flagged `isDefault: true`. ## Connected accounts A connected account is one authorised login: an Instagram professional account, a LinkedIn page, a YouTube channel, a WhatsApp number, a Meta ad account, a Shopify store. You create them through a connect flow ([connecting accounts](https://pozy.ai/docs/connect)), never with a password typed into your own app. Fields you will use often: | Field | Meaning | | --- | --- | | `_id` | The account id. Posts, messages and analytics calls take it as `accountId`. | | `platform` | The platform id, such as `instagram`, `twitter` (X) or `googlebusiness`. | | `profileId` | The profile it lives in. | | `username`, `displayName` | How the account appears on the platform. | | `isActive` | `false` once it is disconnected. | | `needsReconnection` | `true` when the platform revoked access and the owner has to reconnect. | Account ids are stable for the life of the connection. When access lapses, run the connect flow again with `reconnectAccountId` set to the existing id: the account keeps its id and history ([account health](https://pozy.ai/docs/connect/account-health)). ## Posts and targets A post is one piece of content sent to one or more accounts. Each account it goes to is a **target**, an entry in `platforms[]`: ```json { "content": "Default text for every target", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/launch.jpg" }], "platforms": [ { "platform": "instagram", "accountId": "6720f41a8d3be96c1e5a02f7" }, { "platform": "linkedin", "accountId": "6720f5c2e17a4b08d9f3a61b", "customContent": "A longer version for LinkedIn." } ], "scheduledFor": "2026-11-03T10:00:00", "timezone": "America/New_York" } ``` The post has an overall `status` (`draft`, `pending_approval`, `scheduled`, `publishing`, `published`, `partial`, `failed`, `cancelled`) and each target has its own status, platform post id and URL. A post where some targets published and others failed ends as `partial`, and you can retry only the failed ones ([post lifecycle](https://pozy.ai/docs/publishing/lifecycle)). Per-target overrides live on the target: `customContent`, `customMedia`, `scheduledFor` and `platformSpecificData` for options only one platform understands ([platform settings](https://pozy.ai/docs/publishing/platform-settings)). ## Media Attach media by URL in `mediaItems`. For files on your machine or server, ask Pozy for an upload URL with `POST /v1/media/presign`, upload the file there, then use the returned public URL in the post ([media uploads](https://pozy.ai/docs/publishing/media)). Media is served from `https://pozy.ai/media/...`. ## Webhooks A webhook endpoint is a URL plus a list of events. Pozy sends a signed `POST` each time one of those events happens: a post published, an account disconnected, a DM received, an ad rejected. Endpoints can be narrowed to some profiles or accounts ([webhooks](https://pozy.ai/docs/webhooks)). ## API keys API keys authenticate server-to-server requests. A key belongs to the workspace, not to a person, and keeps working when the member who created it leaves. Keys can be limited to some profiles, made read-only, or barred from whole areas of the API ([authentication](https://pozy.ai/docs/getting-started/authentication)). ## Ids and formats - Ids are opaque strings. Store them as text and never parse them. - Times are ISO 8601. Responses use UTC (`2026-11-03T15:00:00.000Z`). Requests may send a local time plus a `timezone` (an IANA name such as `Europe/Madrid`) where an endpoint accepts one. - Field names are camelCase (`accountId`, `scheduledFor`). A few query parameters are spelled the platform's way, such as `redirect_url` on connect calls. Unknown body fields are ignored, so a misspelled field fails quietly: check the response. Some list endpoints, such as `GET /v1/posts`, reject unknown query parameters with a `400` that names them. - Platform ids are lowercase and stable. X is `twitter` in the API. --- # Authentication Authenticate with API keys, limit them to some profiles, make them read-only or bar whole areas of the API, and rotate them safely. Every request to `https://api.pozy.ai/v1` carries a bearer credential in the `Authorization` header. For your own servers that credential is an API key. The MCP server signs in with OAuth, and the dashboard uses your browser session. All of them hit the same API with the same rules. ```bash Terminal curl https://api.pozy.ai/v1/accounts \ -H "Authorization: Bearer $POZY_API_KEY" ``` ## API keys A key looks like `pz_live_` followed by 64 hex characters. It belongs to the workspace, not to the person who created it, so it keeps working when that member leaves. Pozy stores only a SHA-256 hash of each key. The full value is returned once, when you create it. Lists show a preview such as `pz_live_3f9a...c41e` so you can tell keys apart. > **Keep keys on the server:** > > A key can do anything its settings allow, including publishing and spending money on ads, numbers and messages. Never ship one in a mobile app, a browser bundle or a public repository. If one leaks, delete it at once and create a new one. ### Create a key in the dashboard Open [API keys](https://pozy.ai/dashboard/api-keys), click **Create key**, pick a name and the settings below, and copy the value. Workspace owners and admins can create keys. ### Create a key with the API `POST /v1/api-keys` creates a key from code, for example when you onboard a customer and want a key that only reaches their profile. The call needs a full-access, read-write key (or an owner or admin session). ```bash cURL curl -X POST https://api.pozy.ai/v1/api-keys \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Reporting job", "permission": "read", "disabledResourceGroups": ["billing", "webhooks"], "expiresIn": 90 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/api-keys", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Reporting job", permission: "read", disabledResourceGroups: ["billing", "webhooks"], expiresIn: 90, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.apiKey.key); // shown once ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/api-keys", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "Reporting job", "permission": "read", "disabledResourceGroups": ["billing", "webhooks"], "expiresIn": 90, }, ) print(resp.json()["apiKey"]["key"]) # shown once ``` ```json { "message": "API key created", "apiKey": { "id": "key_Vb3nQ8rT1xLm6Zc0Wp9s", "name": "Reporting job", "key": "pz_live_3f9a0c7e...", "keyPreview": "pz_live_3f9a...c41e", "scope": "full", "profileIds": [], "permission": "read", "disabledResourceGroups": ["billing", "webhooks"], "expiresAt": "2027-01-28T10:12:44.000Z", "createdAt": "2026-10-30T10:12:44.000Z" } } ``` ### Key settings | Field | Values | Default | Effect | | --- | --- | --- | --- | | `name` | text | required | Label shown in lists and logs. | | `scope` | `full`, `profiles` | `full` | `profiles` limits the key to the profiles in `profileIds`. | | `profileIds` | profile ids | none | Required when `scope` is `profiles`. Every id must belong to the workspace. | | `permission` | `read-write`, `read` | `read-write` | `read` allows only `GET` requests. | | `disabledResourceGroups` | group names | none | Areas of the API the key may not call. See the table below. | | `expiresIn` | days | never expires | The key stops working after that many days. | ## Profile-scoped keys A key with `scope: "profiles"` is limited to the profiles you list. Any request that names a profile outside that list, in the path, the query string or the body, fails with `403` and `code: "profile_out_of_scope"`. A request that names an account (`accountId`, `accountIds`, `targetAccountId` or `sourceAccountId`) belonging to another profile fails with `403` and `code: "account_out_of_scope"`. `GET /v1/profiles` returns only the profiles in scope. Use one per customer when you [build a platform](https://pozy.ai/docs/build/tenant-keys), so a bug or a leaked key in one tenant cannot touch another. With a scoped key, always pass `profileId` on list calls (accounts, posts, conversations) so results stay inside the tenant. Scoped keys cannot manage API keys, and they cannot read workspace-wide billing. Calls that need the whole workspace return `403` with `code: "profile_scoped_key"`. ## Read-only keys With `permission: "read"`, any request other than `GET` fails before it runs: ```json { "error": "This credential is read-only.", "type": "permission_error", "code": "read_only_key" } ``` Give read-only keys to dashboards, reporting jobs, BI tools and AI assistants that only need to look. ## Disabled resource groups Every endpoint belongs to one resource group. A key with a group in `disabledResourceGroups` gets `403` with `code: "resource_group_disabled"` on any endpoint in that group, read or write. | Group | Covers | | --- | --- | | `publishing` | Posts, queues, validation tools, media uploads, blog articles | | `engagement` | Comments, reviews, mentions, likes and reposts, Discord server actions, Google Business reviews | | `messages` | DMs and conversations, broadcasts, sequences, comment-to-DM, WhatsApp and RCS messages, email, voice agents | | `contacts` | Contacts, custom field values, ad lead forms and leads, WhatsApp blocked users | | `analytics` | Post, account and inbox analytics | | `ads` | Ad accounts, campaigns, ad sets, creatives, audiences, conversions, pixels, catalogs, rules, insights | | `telephony` | Phone numbers, SMS, voice calls, branded calling, WhatsApp numbers and calling, verification codes | | `accounts` | Connect flows, connected accounts, profiles, account groups, account settings, workflows, WhatsApp templates and flows, Google Business locations | | `billing` | Usage and billing | | `webhooks` | Webhook endpoints, delivery logs, test deliveries | | `commerce` | Stores, products, orders and other commerce data | | `creator_campaigns` | Creator campaigns, participants and submissions | | `money` | Balance, top-ups, payees and transfers | | `calendar` | Calendar events | | `files` | Files | The [API reference](https://pozy.ai/docs/api) shows each endpoint's group under **Authentication**. > **Least privilege:** > > Start from the narrowest key that works. A scheduling backend needs `publishing` and `accounts`, so it can disable `ads`, `telephony`, `money`, `billing` and `webhooks`. A support inbox needs `messages`, `engagement` and `contacts`. ## Rotate and revoke keys Deleting a key revokes it immediately. Requests that use it start failing with `401` on the next call. To rotate without downtime: 1. Create the new key with the same settings. 2. Deploy it everywhere the old key is used. 3. Check the old key's **Last used** time in the dashboard. Once it stops moving, delete it. ```bash cURL curl -X DELETE https://api.pozy.ai/v1/api-keys/key_Vb3nQ8rT1xLm6Zc0Wp9s \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/api-keys/key_Vb3nQ8rT1xLm6Zc0Wp9s", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.delete( "https://api.pozy.ai/v1/api-keys/key_Vb3nQ8rT1xLm6Zc0Wp9s", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, ) ``` ## Check a credential `GET /v1/auth/verify` returns `valid: true` with the credential type (`api_key`, `oauth` or `session`), its scope and the workspace it belongs to. Use it in health checks and when you debug a `401` or `403`. ## OAuth for the MCP server The [MCP server](https://pozy.ai/docs/mcp) at `https://mcp.pozy.ai/mcp` signs you in through the browser with your Pozy account. It acts as you, so your workspace role applies: a `viewer` gets read-only access, and a `member` cannot manage keys, webhooks or billing. You can revoke these sessions from **Settings → Connected apps** in the dashboard. Clients that cannot do OAuth can send an API key as the bearer token instead. ## Dashboard sessions The dashboard calls the same public API with your session cookie. Write requests from another site are refused with `code: "csrf_rejected"`, so a session cannot be used from a page you do not trust. Dashboard traffic has its own rate limit budget, 120 requests a minute per workspace, and does not eat into your keys' limit. ## Errors you may see | Status | `code` | Meaning | Fix | | --- | --- | --- | --- | | 401 | `invalid_api_key` | Missing, mistyped, revoked or expired credential. | Check the `Authorization: Bearer` header and the key's expiry. | | 403 | `read_only_key` | A write with a read-only key. | Use a read-write key. | | 403 | `resource_group_disabled` | The endpoint's group is disabled on this key. | Use a key that allows the group. | | 403 | `insufficient_permissions` | The key type cannot do this, for example a scoped or read-only key managing keys. | Use a full-access, read-write key. | | 403 | `role_forbidden` | Your workspace role cannot do this (sessions and OAuth tokens). | Ask an owner or admin for a different role. | | 403 | `profile_out_of_scope` | A profile-scoped key named a profile it does not cover. | Use a key scoped to that profile. | | 403 | `account_out_of_scope` | A profile-scoped key named an account that belongs to another profile. | Check the account id, or use a key scoped to that account's profile. | | 403 | `profile_scoped_key` | A profile-scoped key called a workspace-wide endpoint. | Pass a `profileId` where the endpoint accepts one, or use a full-access key. | | 403 | `feature_not_available` | The feature is not enabled for the workspace. | Contact support@pozy.ai. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/auth/verify`](https://pozy.ai/docs/api/verifyCredential) | Verify credential | | [`GET /v1/api-keys`](https://pozy.ai/docs/api/listApiKeys) | List keys | | [`POST /v1/api-keys`](https://pozy.ai/docs/api/createApiKey) | Create key | | [`DELETE /v1/api-keys/{keyId}`](https://pozy.ai/docs/api/deleteApiKey) | Delete key | --- # Errors Read the error envelope, branch on type and code, know which failures to retry, and handle per-platform publishing errors. Pozy uses HTTP status codes and returns a JSON body on every failed request. Branch your code on the HTTP status and the `code` field. Show `error` to people, but never parse it: the wording can change. ## The error envelope ```json { "error": "`name` must be 1 to 50 characters.", "type": "invalid_request_error", "code": "invalid_field", "param": "name" } ``` | Field | Always present | Meaning | | --- | --- | --- | | `error` | Yes | A sentence for humans. May be reworded at any time. | | `type` | Usually | The class of error. See the table below. | | `code` | Usually | A stable, machine-readable reason, such as `read_only_key`. | | `param` | When one field is at fault | The field or query parameter, dotted when nested. | | `details` | Sometimes | Structured context: the conflicting id, field errors, limits. | | `platform`, `platformError` | When a platform refused | Which platform said no, and its original error payload. | Some endpoints add their own fields next to these. Payment errors carry `reason` and `dashboard_url`, for example ([billing](https://pozy.ai/docs/billing/billing)). ## Status codes and types | Status | `type` | When | | --- | --- | --- | | 400 | `invalid_request_error` | A field is missing or invalid, the JSON is malformed, or a precondition is not met. | | 401 | `authentication_error` | No credential, or it is invalid, revoked or expired. | | 402 | `payment_required` | The action needs a payment method or would pass a spend cap, or the workspace is paused because a payment failed. | | 403 | `permission_error` | The credential is valid but not allowed: read-only key, disabled group, role, scope, an unconfirmed email address, a feature that is not enabled, or a workspace paused by Pozy. | | 404 | `not_found` | The resource does not exist, or this credential cannot see it. | | 405 | `invalid_request_error` | The path exists but not with this method. `error` lists the allowed methods. | | 409 | `invalid_request_error` | A conflict: a duplicate post, an `externalId` already in use, a queue slot already taken, or a request with the same `Idempotency-Key` still running. | | 422 | `invalid_request_error` | The request is well formed but cannot be processed as sent. | | 429 | `rate_limit_error` | Too many requests, or a daily cap was reached. Wait `Retry-After` seconds when present ([rate limits](https://pozy.ai/docs/getting-started/rate-limits)). | | 500 | `api_error` | Something failed on Pozy's side. | | 501 | `api_error` | The endpoint exists in the spec but is not available yet (`not_implemented`). | | 502 | `platform_error` or `api_error` | A platform returned an error or did not answer, or Pozy could not send an email. | | 503 | `platform_error` | A platform service Pozy relies on is unavailable right now. Nothing is wrong with your request. Retry later, after `Retry-After` seconds when present. | ## Common codes | `code` | Status | Meaning | | --- | --- | --- | | `invalid_api_key` | 401 | Missing or invalid credential. | | `read_only_key` | 403 | A write with a read-only key. | | `resource_group_disabled` | 403 | The key cannot use this area of the API. | | `insufficient_permissions` | 403 | The key type cannot do this. | | `role_forbidden` | 403 | Your workspace role cannot do this. | | `profile_out_of_scope` | 403 | A profile-scoped key named another profile, or reached for the balance, transfers or payouts. | | `account_out_of_scope` | 403 | A profile-scoped key named an account that belongs to another profile. | | `feature_not_available` | 403 | Not enabled for the workspace. Contact support@pozy.ai. | | `payment_required` | 402 | Add a payment method to continue. | | `workspace_suspended` | 402 | The workspace is paused because a payment failed. Update the card in Billing to resume. Reads, billing and paying keep working; writes, connects and publishing are refused. | | `workspace_suspended` | 403 | Pozy paused the workspace. Write to support@pozy.ai to resume it. | | `email_not_verified` | 403 | Confirm your email address first, or add a payment method. Needed to send invitation emails and to connect accounts without a payment method. | | `missing_field`, `missing_required_field` | 400 | A required field is absent. `param` names it. | | `invalid_field`, `invalid_field_value` | 400 | Wrong type, format or value. `param` names it. | | `method_not_allowed` | 405 | Wrong HTTP method for this path. | | `endpoint_not_found` | 404 | No endpoint at this method and path. Check for typos and the `/v1` prefix. | | `resource_not_found` | 404 | The id is unknown or not visible to this credential. Specific variants exist, such as `profile_not_found` and `webhook_not_found`. | | `rate_limit_exceeded` | 429 | Your workspace used its requests for this minute. | | `rate_limited` | 429 | A platform or a per-endpoint quota said wait. | | `invite_limit_reached` | 429 | The workspace sent its invitation emails for today (20 without a payment method, 500 with one), or has 50 email invitations waiting without a payment method. Share an invite link instead. | | `invite_resend_too_soon` | 429 | An invitation can be emailed again once an hour. | | `support_ticket_limit` | 429 | A workspace without a payment method sent its 5 questions to the Pozy team for today. Write to support@pozy.ai for anything urgent. | | `idempotency_key_in_use` | 409 | A request with this `Idempotency-Key` is still running. Retry in a moment with the same key. | | `email_not_sent` | 502 | Pozy could not send the email. Copy the invite link and share it yourself. | | `service_unavailable` | 503 | Pozy cannot reach a platform service it relies on. Retry later. | | `temporarily_unavailable` | 503 | A platform service Pozy relies on is temporarily unavailable, for example for new connections. Retry later. | | `platform_api_error` | 4xx or 502 | The platform refused the call. Read `platform` and `platformError`. | | `not_implemented` | 501 | Not available yet. | New codes can appear at any time, so always keep a default branch. ## Platform errors When Instagram, Google, Meta or another platform refuses a request, the response says which one and includes its payload untouched: ```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." } } ``` A platform `4xx` usually means the input needs fixing. A `502` means the platform failed or timed out, and a retry often works. ## Publishing errors live on the post A scheduled post fails later, long after your request returned `200`. Those failures do not come back as an error response. Instead, the post ends with `status: "failed"` (no target published) or `"partial"` (some did), and each failed entry in `platforms[]` explains why: ```json { "platform": "instagram", "status": "failed", "errorMessage": "The aspect ratio of this video is not supported for reels.", "errorCategory": "user_content", "errorSource": "user" } ``` | `errorCategory` | Meaning | What to do | | --- | --- | --- | | `auth_expired` | The platform revoked access. | Ask the owner to reconnect the account, then retry. | | `user_content` | The content broke a platform rule: length, format, media size. | Fix the post and retry. | | `user_abuse` | The platform flagged the content or the account. | Review the content. Do not retry as is. | | `account_issue` | The account is restricted or missing a permission. | Fix it on the platform side. | | `platform_rejected` | The platform refused without a clearer reason. | Read `errorMessage`, adjust, retry. | | `platform_error` | The platform failed on its side. | Retry later. | | `platform_rate_limit` | The platform throttled the account. | Retry later. | | `quota_exhausted` | A daily or per-app quota is used up. | Retry after the quota resets. | | `system_error` | Pozy failed to publish. | Retry. Contact support if it repeats. | | `unknown` | Not classified. | Read `errorMessage`. | `errorSource` tells you who has to act: `user`, `platform` or `system`. `POST /v1/posts/{postId}/retry` runs only the failed targets again. Subscribe to `post.failed`, `post.partial` and `post.platform.failed` to hear about these as they happen ([post lifecycle](https://pozy.ai/docs/publishing/lifecycle)). > **207 is a success code:** > > Publishing now (`publishNow: true`) or retrying can return `207 Multi-Status` when the post was saved but at least one platform failed. Most HTTP clients treat `207` as success, so check `post.status` as well as the status code. ## What to retry | Response | Retry? | How | | --- | --- | --- | | `429` | Yes | Wait `Retry-After` seconds, then send the same request. | | `500`, `502`, `503` | Yes | Back off exponentially (1 s, 2 s, 4 s, up to about a minute). For creates, send the same `Idempotency-Key` ([idempotency](https://pozy.ai/docs/getting-started/idempotency)). | | Network timeout | Yes, carefully | The request may have succeeded. Retry with the same `Idempotency-Key`, or read the resource first. | | `409` `idempotency_key_in_use` | Yes | Wait a moment, then send the same request with the same `Idempotency-Key`. | | Other `409` | No | Read `details`. A duplicate post comes with the id of the existing one. | | Other `4xx` | No | Fix the request. Retrying the same body fails the same way. | ## Request ids Every response carries an `X-Request-Id` header. Log it with the error. Support can trace any request from that id ([support](https://pozy.ai/docs/resources/support)). You can send your own `x-request-id` (for example the id of your job) and Pozy uses it for that request, so your logs and Pozy's line up. ```bash Terminal curl -i https://api.pozy.ai/v1/posts/6721a0c47b2e5f9d3a18c6e0 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "x-request-id: 3e0f7a52-9b1c-4c6d-8f2e-5a7b9c1d3e4f" ``` ## Handle errors in code ```ts Node // A network error throws here: it is safe to retry with the same Idempotency-Key. const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Hello", platforms: [] }), }); if (!res.ok) { const body = await res.json(); const requestId = res.headers.get("x-request-id"); if (res.status === 429 || res.status >= 500) { // retry later, see "What to retry" } else if (body.type === "invalid_request_error") { console.error(`Fix ${body.param ?? "the request"}:`, body.error, body.details); } else { console.error(res.status, body.code, body.error, `request ${requestId}`); } } ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"content": "Hello", "platforms": []}, timeout=30, ) if not resp.ok: body = resp.json() request_id = resp.headers.get("x-request-id") if resp.status_code == 429 or resp.status_code >= 500: pass # retry later, see "What to retry" else: raise RuntimeError(f"{resp.status_code} {body.get('code')}: {body.get('error')} (request {request_id})") ``` --- # Rate limits Requests per minute on each plan, the headers that report your budget, how to back off on a 429, and the platform limits that apply on top. Pozy limits how many API requests a workspace can make per minute. The limit is shared by every API key and MCP connection in the workspace. Requests the dashboard makes have their own budget of 120 per minute for each workspace, so a busy team in the app never slows your integration down. ## Limits by plan | Plan | Requests per minute | When it applies | | --- | --- | --- | | Free | 30 | No payment method on file. | | Build | 120 | A payment method is on file. | | Enterprise | 300 | An enterprise contract, or more than 2,000 connected accounts. | The window is a fixed calendar minute. The count resets at the start of each minute, not 60 seconds after your first request. If you need more than 300 requests per minute, write to support@pozy.ai with your use case. ### Busy periods Your workspace limit is the most you can send, not a guarantee. When the platform as a whole is very busy, Pozy can answer `429` with a `Retry-After` header before your workspace reaches its own limit. The body has the same `type` and `code` as below, with a message such as `Pozy is busy right now. Retry in 5s.` Analytics endpoints have a tighter per-second budget, so spread bulk analytics reads over time. Back off the same way in every case: wait `Retry-After` seconds, then send the request again. ## Read your budget from the headers Every response tells you where you stand: ```bash Terminal curl -i https://api.pozy.ai/v1/accounts \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```http HTTP/2 200 x-ratelimit-limit: 120 x-ratelimit-remaining: 107 x-ratelimit-reset: 41 x-request-id: req_P3sK8vN1qL7xT2mR9cZa ``` | Header | Meaning | | --- | --- | | `X-RateLimit-Limit` | Requests allowed in the current minute. | | `X-RateLimit-Remaining` | Requests left in the current minute. | | `X-RateLimit-Reset` | Seconds until the window resets. | | `Retry-After` | On `429` only: seconds to wait before retrying. | The reset value is a number of seconds, not a timestamp, so clock skew between your servers and Pozy does not matter. ## When you hit the limit The request is refused before it runs, so nothing happened and it is always safe to send again: ```json { "error": "Too many requests. Retry in 41s.", "type": "rate_limit_error", "code": "rate_limit_exceeded" } ``` Wait for `Retry-After` seconds, then retry. A small random delay on top stops many workers from retrying in the same second. ```ts Node async function withRetry(call: () => Promise, attempts = 5): Promise { for (let i = 0; ; i++) { const res = await call(); if (res.status !== 429 && res.status < 500) return res; if (i === attempts - 1) return res; const retryAfter = Number(res.headers.get("retry-after")); const base = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** i; await new Promise((r) => setTimeout(r, base + Math.random() * 500)); } } const res = await withRetry(() => fetch("https://api.pozy.ai/v1/accounts", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }), ); console.log(await res.json()); ``` ```python Python import os, random, time, requests def with_retry(send, attempts=5): for i in range(attempts): resp = send() if resp.status_code != 429 and resp.status_code < 500: return resp if i == attempts - 1: return resp retry_after = resp.headers.get("retry-after") delay = float(retry_after) if retry_after else 2 ** i time.sleep(delay + random.random() * 0.5) resp = with_retry(lambda: requests.get( "https://api.pozy.ai/v1/accounts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, timeout=30, )) print(resp.json()) ``` > **Retrying writes:** > > A `429` is always safe to retry. A `5xx` or a timeout on a create may have gone through. Send an `Idempotency-Key` on creates so a retry cannot make a duplicate ([idempotency](https://pozy.ai/docs/getting-started/idempotency)). ## Platform limits on top Your Pozy budget is not the only limit. Every platform caps what one account can do, and some endpoints have quotas of their own. When one of those says stop, you get `429` with `code: "rate_limited"`, plus `Retry-After` whenever the platform said how long to wait. To keep accounts in good standing, Pozy also caps how often one account publishes: | Limit | Value | | --- | --- | | Posts per account per hour | 25 | | Posts per account per day | Instagram 100, Facebook 100, Threads 250, X 50, Pinterest 25, TikTok 15 videos and 15 photos, every other platform 50 | A post over a cap is refused with the cooldown in the message. Spread large batches over time with a [queue](https://pozy.ai/docs/publishing/queues) instead. Platform rules change often. The per-platform pages under [Platforms](https://pozy.ai/docs/platforms) list the limits that matter for each one. ## Stay under the limit - **Use webhooks instead of polling.** One `post.published` event replaces dozens of `GET /v1/posts/{postId}` calls ([webhooks](https://pozy.ai/docs/webhooks)). - **Page with large pages.** Most list endpoints accept a `limit` far above the default ([pagination](https://pozy.ai/docs/getting-started/pagination)). - **Upload many posts at once.** `POST /v1/posts/bulk-upload` takes a CSV instead of one request per post ([CSV import](https://pozy.ai/docs/publishing/csv-import)). - **Sync incrementally.** `GET /v1/posts?updatedSince=...` returns only what changed since your last sync. - **Cache what rarely changes.** Account lists, profiles and queue schedules can be cached for minutes and refreshed on `account.connected` and `account.disconnected` events. - **Watch the headers.** Slow down when `X-RateLimit-Remaining` gets low instead of waiting for a `429`. More keys do not give you more requests: the budget belongs to the workspace. Use separate keys for access control, not for throughput. --- # Idempotency and safe retries Send an Idempotency-Key so a retried create returns the original result instead of a second post, message, call or ad. Networks drop responses. A job runner retries a step, a no-code tool fires a node twice, a user double-clicks. Without protection, each of those can publish the same post twice or send the same SMS twice. The `Idempotency-Key` header stops that: Pozy remembers the result of the first request with a given key and hands it back for any repeat. ## Send a key on creates Generate one key per logical action, store it next to the job, and send the same key on every attempt. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0b6f1c2e-7a43-4d8e-9f15-3c2a8e4b7d91" \ -d '{ "content": "Doors open at 9.", "platforms": [{ "platform": "linkedin", "accountId": "6720f41a8d3be96c1e5a02f7" }], "scheduledFor": "2026-11-05T09:00:00", "timezone": "Europe/London" }' ``` ```ts Node import { randomUUID } from "node:crypto"; const key = job.idempotencyKey ?? (job.idempotencyKey = randomUUID()); // persist with the job const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": key, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Doors open at 9.", platforms: [{ platform: "linkedin", accountId: "6720f41a8d3be96c1e5a02f7" }], scheduledFor: "2026-11-05T09:00:00", timezone: "Europe/London", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); ``` ```python Python import os, uuid, requests key = job.get("idempotency_key") or str(uuid.uuid4()) # persist with the job resp = requests.post( "https://api.pozy.ai/v1/posts", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": key, }, json={ "content": "Doors open at 9.", "platforms": [{"platform": "linkedin", "accountId": "6720f41a8d3be96c1e5a02f7"}], "scheduledFor": "2026-11-05T09:00:00", "timezone": "Europe/London", }, timeout=30, ) ``` Rules for keys: - Any string up to 255 characters. A UUID v4 is the simplest choice. A longer key returns `400`. - One key per action. Reusing a key for a different post returns the first post, not a new one. - Keys are remembered for 24 hours and are scoped to your workspace. - Generate the key before the first attempt, not inside the retry loop. ## What a repeat returns For posts, the first request creates the post and returns `201`. A repeat with the same key within 24 hours creates nothing and returns `200` with the original post, even if the body changed in between. Treat both as success. ```json { "message": "Post already exists (idempotent retry)", "post": { "_id": "6721a0c47b2e5f9d3a18c6e0", "status": "scheduled" } } ``` If the first request is still being processed when the repeat arrives, you get `409` with `code: "idempotency_conflict"` and a `Retry-After` header. Wait that long and send the same request again. For the other endpoints below, only successful responses are remembered. If the first attempt failed, the key is released and the retry runs for real. The same key sent to a different path, or with a different body, returns `422`. ## Endpoints that accept a key | Area | Endpoints | | --- | --- | | Publishing | [Create post](https://pozy.ai/docs/api/createPost), [create blog article](https://pozy.ai/docs/api/createBlogArticle) | | Inbox | [Send message](https://pozy.ai/docs/api/sendInboxMessage), [reply to comments](https://pozy.ai/docs/api/replyToInboxPost), [reply to a review](https://pozy.ai/docs/api/replyToInboxReview) | | Phone and messaging | [Send SMS](https://pozy.ai/docs/api/sendSms), [send RCS message](https://pozy.ai/docs/api/sendRcsMessage), [create RCS agent](https://pozy.ai/docs/api/createRcsAgent), [start a voice call](https://pozy.ai/docs/api/createVoiceCall), [start a WhatsApp call](https://pozy.ai/docs/api/initiateWhatsAppCall) | | Branded calling | [Create enterprise](https://pozy.ai/docs/api/createBrandedCallingEnterprise), [create identity](https://pozy.ai/docs/api/createBrandedCallingIdentity) | | Ads | [Create campaign](https://pozy.ai/docs/api/createAdCampaign), [create ad set](https://pozy.ai/docs/api/createAdSet), [create ad](https://pozy.ai/docs/api/createStandaloneAd), [boost a post](https://pozy.ai/docs/api/boostPost), [messaging ad](https://pozy.ai/docs/api/createMessagingAd), [call ad](https://pozy.ai/docs/api/createCallAd), and the duplicate endpoints for campaigns, ad sets and ads | | Creator campaigns | Create campaign, raise budget, add participant, approve, reject or reopen a submission, adjustments, payout batches | | Money | Top-ups and transfers | | Support | [Ask the support assistant](https://pozy.ai/docs/api/askSupportQuestion) | The [API reference](https://pozy.ai/docs/api) lists `Idempotency-Key` under the header parameters of every endpoint that takes it. Other writes are either updates (sending them twice leaves the same result) or cheap to check first. > **Profiles use externalId:** > > To make profile creation retry-safe, set `externalId` to your own customer id. A second create with the same `externalId` returns `409` with `code: "profile_external_id_conflict"` and the existing profile's id in `details.existingProfileId`, so you can carry on with that profile ([profiles](https://pozy.ai/docs/build/profiles)). ## Duplicate content protection Separately from keys, Pozy refuses to publish the exact same content to the same account twice within 24 hours. A match on the account, the text and the media URLs returns `409`: ```json { "error": "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours.", "details": { "accountId": "6720f41a8d3be96c1e5a02f7", "platform": "linkedin", "existingPostId": "6721a0c47b2e5f9d3a18c6e0" } } ``` Only scheduled, publishing and published targets count. Failed and cancelled ones do not. To repost on purpose, change the text, the media or the account. If you send your own `x-request-id` and a retry carries the same request id and the same content, Pozy treats it as a retry and returns the original post rather than this `409`. `Idempotency-Key` is still the better tool: it works even when the body changes. ## A safe retry recipe 1. Create the key and save it with your job before the first attempt. 2. Send the request with the key. 3. On a timeout, a connection error, `429` or `5xx`: wait (honour `Retry-After`, otherwise back off 1 s, 2 s, 4 s...), then send the identical request with the same key. 4. On `200` or `201`: store the returned id. You are done. 5. On `409 idempotency_conflict`: wait `Retry-After` and go back to step 3. 6. On any other `4xx`: stop and fix the request. Retrying will not help. > **In workflow tools:** > > In n8n, Make or Zapier, create a fresh key per HTTP step. One key shared by several steps turns every step after the first into a "retry" of the first, and nothing else gets created. --- # Pagination Page through list endpoints with page and limit, skip, or cursors, and keep a local copy in sync with updatedSince. List endpoints return results a page at a time. Most use one of three styles: numbered pages, offsets, or opaque cursors. Each endpoint's parameters in the [API reference](https://pozy.ai/docs/api) tell you which one it takes. This page shows how to walk each style to the end. ## Numbered pages: page and limit Posts, accounts, analytics and most ads lists take `page` (starting at 1) and `limit`, and return a `pagination` object: ```bash cURL curl "https://api.pozy.ai/v1/posts?status=published&page=1&limit=100" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const all = []; for (let page = 1; ; page++) { const res = await fetch(`https://api.pozy.ai/v1/posts?${new URLSearchParams({ status: "published", page: String(page), limit: "100" })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); all.push(...data.posts); if (page >= data.pagination.pages) break; } ``` ```python Python import os, requests headers = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} posts, page = [], 1 while True: resp = requests.get( "https://api.pozy.ai/v1/posts", headers=headers, params={"status": "published", "page": page, "limit": 100}, timeout=30, ).json() posts += resp["posts"] if page >= resp["pagination"]["pages"]: break page += 1 ``` ```json { "posts": [ { "_id": "6721a0c47b2e5f9d3a18c6e0", "status": "published" } ], "pagination": { "page": 1, "limit": 100, "total": 342, "pages": 4 } } ``` `GET /v1/posts` defaults to 10 per page and accepts up to 500. `GET /v1/accounts` returns every account when you send no paging parameters at all, and up to 500 per page when you do. Numbered pages can shift if items are added or removed while you read: a new post can push an old one onto the next page, so you see it twice. For a full export, prefer a cursor where the endpoint offers one, or dedupe by `_id`. ## Offsets: skip and limit Webhook delivery logs, tracking windows, creator payouts and money endpoints take `skip` (how many to jump over) and `limit`. The response says whether more remain: ```bash Terminal curl "https://api.pozy.ai/v1/webhooks/logs?status=failed&limit=100&skip=200" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "logs": [ { "_id": "dlv_0c1Fq8Lr2Zt7Wm4Kx9Pa", "event": "post.published", "status": "failed" } ], "pagination": { "total": 241, "skip": 200, "limit": 100, "hasMore": false } } ``` Add `limit` to `skip` for the next page and stop when `hasMore` is `false`. ## Cursors: cursor and nextCursor Inbox conversations, comments, leads, follower stats, the analytics change feed and most newer endpoints use cursors. Send `limit` alone for the first page, then pass the `nextCursor` you got back as `cursor`. Stop when `nextCursor` is `null`. ```bash cURL curl "https://api.pozy.ai/v1/inbox/conversations?limit=100" \ -H "Authorization: Bearer $POZY_API_KEY" # next page curl "https://api.pozy.ai/v1/inbox/conversations?limit=100&cursor=eyJpZCI6IjY3MjE..." \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node let cursor: string | undefined; do { const query = new URLSearchParams({ limit: "100" }); if (cursor) query.set("cursor", cursor); const res = await fetch(`https://api.pozy.ai/v1/inbox/conversations?${query}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.data) handle(c); cursor = data.pagination.nextCursor ?? undefined; } while (cursor); ``` ```python Python cursor = None while True: params = {"limit": 100} if cursor: params["cursor"] = cursor resp = requests.get( "https://api.pozy.ai/v1/inbox/conversations", headers=headers, params=params, timeout=30, ).json() for c in resp["data"]: handle(c) cursor = resp["pagination"].get("nextCursor") if not cursor: break ``` Cursors are opaque. Pass them back exactly as you got them: do not parse, build or compare them. A page can hold fewer items than `limit`, or even none, while `nextCursor` is still set. Keep going until it is `null`. `GET /v1/accounts` takes a cursor too: send `limit` without `page` and follow `pagination.nextCursor`. Cursor pages are ordered by account id, so accounts added or removed mid-walk never shift a page. You cannot combine `cursor` with `page`, `sort` or `perProfile`. ## Other styles you will meet | Parameters | Used by | How to walk | | --- | --- | --- | | `before` + `limit` | Changelog, call history | Pass the previous response's `nextCursor` as `before`. It is a timestamp; stop when it is `null`. | | `after` + `limit` | Reddit search, Discord members, WhatsApp groups, some ads reports | Pass the returned `after` (or `paging.after`) back until it is empty. | | `pageToken` | Google Business reviews, media and keywords | Pass the returned `nextPageToken` as `pageToken`. | | `since` + `until` | Account insights | Date ranges, not pages. Split long ranges into smaller windows. | Some platform-backed lists inherit the platform's quirks, for example a cursor that must be reused with the same `limit`. The platform pages call these out. ## Keep a local copy in sync To mirror posts into your own database, do one full walk, then ask only for what changed: ```bash Terminal curl "https://api.pozy.ai/v1/posts?updatedSince=2026-10-30T09:00:00Z&limit=500" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Store the time you started each sync and use it as the next `updatedSince`, a minute or two earlier to cover clock drift. Better still, update your copy from [webhooks](https://pozy.ai/docs/webhooks) (`post.published`, `post.failed` and the rest) and run the `updatedSince` sync as a nightly safety net. ## Tips - Use the largest `limit` the endpoint allows when you read everything. Fewer pages means fewer requests against your [rate limit](https://pozy.ai/docs/getting-started/rate-limits). - Filter on the server (`profileId`, `accountId`, `platform`, `status`, date ranges) instead of fetching everything and filtering yourself. - Out-of-range values are rejected with `400`, not silently clamped. Check the bounds in the reference. --- # Versioning and changes How the Pozy API changes over time, how deprecations are flagged, and how to follow changes with the changelog endpoint. The Pozy API is one continuously updated surface under `/v1`. There is no version header to pin and no dated releases to opt into. Instead, changes follow a few rules so code you write today keeps working. ## What can change without notice These changes are additive. Write your integration so they never break it: - New endpoints. - New optional request fields and query parameters. - New fields in responses, including new nested objects. - New values in enums that describe state, such as a new post status, error code, platform id or webhook event. - New webhook events (you only receive events you subscribed to). - Reworded `error` messages. Branch on `code`, never on text. In practice: ignore fields you do not know, keep a default branch when you switch on an enum, and do not fail on unknown webhook event types. ## What counts as breaking Removing or renaming an endpoint, a field or an error `code`, making an optional field required, or changing what an existing value means. Breaking changes are announced in the changelog with `type: "breaking_change"` and `impact: "action_required"`, along with what you need to do. ## Deprecations Endpoints that are on their way out stay working and are flagged: - In the [OpenAPI spec](https://pozy.ai/openapi.json), with `"deprecated": true` on the operation. - In the [API reference](https://pozy.ai/docs/api), with a deprecated label on the endpoint page. - In the changelog, as a `deprecation` entry. Move to the replacement when you see the flag. If a deprecated endpoint is later removed, that removal is announced as a `breaking_change` entry. ## The spec version The spec carries a version number in `info.version` (for example `1.424.0`). It goes up with every published change to the spec. Use it to tell whether a client you generated is out of date; it is not something you send with requests. ```bash Terminal curl -s https://pozy.ai/openapi.json | jq -r .info.version ``` ## Follow changes from code `GET /v1/changelog` lists changes newest first. It is public: no key needed. ```bash cURL curl "https://api.pozy.ai/v1/changelog?type=breaking_change&limit=20" ``` ```ts Node // Public endpoint: no API key, so a plain fetch is enough. const res = await fetch("https://api.pozy.ai/v1/changelog?type=breaking_change&limit=20"); const { entries } = await res.json(); for (const entry of entries) console.log(entry.publishedAt, entry.title); ``` ```python Python import requests resp = requests.get( "https://api.pozy.ai/v1/changelog", params={"type": "breaking_change", "limit": 20}, timeout=30, ) for entry in resp.json()["entries"]: print(entry["publishedAt"], entry["title"]) ``` | Query parameter | Values | | --- | --- | | `type` | `new_feature`, `breaking_change`, `improvement`, `deprecation`, `minor` | | `platform` | A platform id, such as `instagram` | | `before` | A timestamp. Pass the previous response's `nextCursor` here for the next page. | | `limit` | 1 to 100, default 50 | Each entry has an `id`, `type`, `impact`, `platforms`, `title`, `summary`, `message`, `publishedAt` and a `url` to read it on the web. `impact` is `action_required` for breaking changes and `additive` for everything else. It is part of each entry, not a filter, so filter on `type=breaking_change` to get the entries that need action. When an entry is tied to spec changes, `changes.endpoints` lists the operations added, removed and modified. A simple routine: once a day, fetch entries with `type=breaking_change` published since your last check and send them to your team's channel. Read the same feed on the web at [/docs/changelog](https://pozy.ai/docs/changelog). ## Generated clients If you generate a client from the spec, regenerate it when `info.version` changes and review the diff. Because changes are additive, an old client keeps working; it just cannot see new fields and endpoints. --- # Build a platform Give each of your customers their own profile, connect their accounts, act on their behalf and route every webhook back to the right customer. If you build a product on Pozy, such as a scheduler, an agency tool or a CRM with social features, your customers' accounts all live in your one workspace. Profiles keep them apart. This page is the blueprint: one profile per customer, your own id on it, and events that come back already labelled with that id. ## The model | Your side | Pozy side | | --- | --- | | Customer `cus_8f3a2` | Profile with `externalId: "cus_8f3a2"` | | Their Instagram, TikTok, LinkedIn | Connected accounts inside that profile | | Your backend | One full-access API key, or one scoped key per customer | | Your webhook handler | One endpoint for everyone, or one per customer filtered by profile | Requests carry the `profileId` in. Webhooks carry `profiles[].externalId` out, so routing an event needs no lookup. A customer with three Facebook Pages and two Instagram accounts connects all five into their single profile. Do not create one profile per account. ## Step 1: create a profile when a customer signs up Use your own customer id as the `externalId` and keep the returned `_id` on your customer record. ```bash cURL curl -X POST https://api.pozy.ai/v1/profiles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "externalId": "cus_8f3a2" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/profiles", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Acme Corp", externalId: "cus_8f3a2" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); await db.customers.update("cus_8f3a2", { pozyProfileId: data.profile._id }); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/profiles", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"name": "Acme Corp", "externalId": "cus_8f3a2"}, ) profile_id = resp.json()["profile"]["_id"] ``` `externalId` is unique in your workspace. If a signup is retried, the second create returns `409` with `code: "profile_external_id_conflict"` and the existing id in `details.existingProfileId`, so you never end up with two profiles for one customer. Lost the id? `GET /v1/profiles?externalId=cus_8f3a2` finds it ([profiles](https://pozy.ai/docs/build/profiles)). ## Step 2: let the customer connect their accounts Ask for a connect URL with the customer's `profileId` and your own `redirect_url`, then send the customer's browser there. ```bash cURL curl "https://api.pozy.ai/v1/connect/instagram?profileId=6720f3b1c9e4a85d2f0b7c14&redirect_url=https://app.example.com/integrations/done" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const query = new URLSearchParams({ profileId, redirect_url: "https://app.example.com/integrations/done" }); const connectRes = await fetch(`https://api.pozy.ai/v1/connect/instagram?${query}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await connectRes.json(); if (!connectRes.ok) throw new Error(data.error); res.redirect(data.authUrl); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/instagram", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": profile_id, "redirect_url": "https://app.example.com/integrations/done"}, ) auth_url = resp.json()["authUrl"] # redirect the customer here ``` The customer signs in on the platform, picks the Page or channel if the platform asks, and lands back on your `redirect_url`. Every screen in between is hosted by Pozy. You can put your own brand on them with the [welcome screen](https://pozy.ai/docs/connect/welcome-screen), or draw the selection steps in your own UI with [headless connect](https://pozy.ai/docs/connect/headless). On your server, listen for `account.connected`. It names the customer through `externalId`: ```json { "id": "evt_2c7e1a9f04b8d3e5", "event": "account.connected", "timestamp": "2026-10-30T14:02:11.000Z", "profiles": [{ "id": "6720f3b1c9e4a85d2f0b7c14", "externalId": "cus_8f3a2" }], "account": { "accountId": "6720f41a8d3be96c1e5a02f7", "profileId": "6720f3b1c9e4a85d2f0b7c14", "platform": "instagram", "username": "acmecorp" } } ``` `account.disconnected` arrives the same way when access ends, so you can show the customer a "Reconnect" button. ## Step 3: act on their behalf Every call that works on accounts, posts, conversations or analytics accepts the customer's `profileId` or their `accountId`s. To post to everything a customer connected: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: cus_8f3a2-post-1042" \ -d '{ "content": "Spring menu is live.", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/spring-menu.jpg" }], "platforms": [ { "platform": "instagram", "accountId": "6720f41a8d3be96c1e5a02f7" }, { "platform": "facebook", "accountId": "6720f52b9a1cd47e3f8b06a2" } ], "scheduledFor": "2026-11-04T11:30:00", "timezone": "America/Chicago" }' ``` ```ts Node const listedRes = await fetch(`https://api.pozy.ai/v1/accounts?${new URLSearchParams({ profileId })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const listed = await listedRes.json(); if (!listedRes.ok) throw new Error(listed.error); const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": `cus_8f3a2-post-${draft.id}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: draft.text, mediaItems: draft.media, platforms: listed.accounts.map((a) => ({ platform: a.platform, accountId: a._id })), scheduledFor: draft.when, timezone: "America/Chicago", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python accounts = requests.get( "https://api.pozy.ai/v1/accounts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": profile_id}, ).json()["accounts"] requests.post( "https://api.pozy.ai/v1/posts", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": f"cus_8f3a2-post-{draft_id}", }, json={ "content": "Spring menu is live.", "platforms": [{"platform": a["platform"], "accountId": a["_id"]} for a in accounts], "scheduledFor": "2026-11-04T11:30:00", "timezone": "America/Chicago", }, ) ``` Derive idempotency keys from your own ids (`cus_8f3a2-post-1042`) so a retried job can never publish twice ([idempotency](https://pozy.ai/docs/getting-started/idempotency)). ## Step 4: route webhooks back to the customer Register one endpoint for all customers and route on `profiles[0].externalId`, or give large customers their own endpoint filtered with `profileIds`. [Route webhooks to tenants](https://pozy.ai/docs/build/route-webhooks) has a complete router. ## Keys: one master key or one per customer A single full-access key on your backend is enough for most platforms. Add [per-customer keys](https://pozy.ai/docs/build/tenant-keys) when part of your system should only ever see one customer, such as a worker that runs customer-written code, a reporting export, or an AI agent acting for one tenant. Scoped keys are for access control only: the [rate limit](https://pozy.ai/docs/getting-started/rate-limits) belongs to the workspace and is shared by every key. ## What it costs Profiles are free, including empty ones. You pay per connected account per month, counted across the whole workspace on the graduated ladder in [Pricing](https://pozy.ai/docs/billing): the more accounts your customers connect in total, the lower the rate for the extra ones. To see what each customer costs you, ask for usage grouped by profile: ```bash Terminal curl "https://api.pozy.ai/v1/usage?range=cycle&groupBy=profile" \ -H "Authorization: Bearer $POZY_API_KEY" ``` `attribution.groups` has one entry per profile. Their sum plus `attribution.unattributed` (workspace-wide items such as the free credit and 10DLC fees) equals the totals ([usage](https://pozy.ai/docs/billing/usage)). > **One workspace, one bill:** > > Profiles separate your customers' data and access, but billing, the rate limit and API access belong to your workspace. If the workspace is paused for an unpaid invoice, every profile and every key stops together. ## Offboard a customer 1. Disconnect each of their accounts with `DELETE /v1/accounts/{accountId}`. An account stops counting toward your bill from the next UTC day. 2. Delete the profile with `DELETE /v1/profiles/{profileId}`. A profile that still has active connected accounts cannot be deleted, so step 1 comes first. 3. Delete or revoke any key you scoped to that profile. A disconnected account can be reconnected into the same profile within one hour and keep its `accountId`. After that it is removed, scheduled posts that only targeted it are cancelled, and a later connection gets a new id. ## Checklist - Create the profile with `externalId` at signup, store `profile._id`. - Connect with the customer's `profileId` and your `redirect_url`. - Handle `account.connected` and `account.disconnected`. - Send `Idempotency-Key` on every create, derived from your own ids. - Route webhooks on `externalId`. - Watch per-customer cost with `GET /v1/usage?groupBy=profile`. --- # Profiles Create, find, update and delete profiles, use externalId for your own ids, set a default profile and timezone, and move accounts between profiles. A profile groups connected accounts under one brand, client or customer. Every workspace starts with a default profile. Add as many more as you need: profiles are free, and only connected accounts are billed. ## Create a profile `name` is required. Everything else is optional. ```bash cURL curl -X POST https://api.pozy.ai/v1/profiles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Northwind Coffee", "description": "Retail client, three locations", "color": "#7C3AED", "externalId": "client_204" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/profiles", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Northwind Coffee", description: "Retail client, three locations", color: "#7C3AED", externalId: "client_204", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.profile._id); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/profiles", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "Northwind Coffee", "description": "Retail client, three locations", "color": "#7C3AED", "externalId": "client_204", }, ) print(resp.json()["profile"]["_id"]) ``` ```json { "message": "Profile created successfully", "profile": { "_id": "6722b8e05d1f4a97c3e2b018", "name": "Northwind Coffee", "description": "Retail client, three locations", "color": "#7C3AED", "timezone": null, "externalId": "client_204", "isDefault": false, "accountCount": 0, "createdAt": "2026-10-30T11:20:05.000Z" } } ``` | Field | Notes | | --- | --- | | `name` | Required. Shown in the dashboard and in connect screens you brand. | | `description` | Free text for your team. | | `color` | Hex colour used to tell profiles apart in the dashboard. | | `externalId` | Your own id for this profile, up to 255 characters. Unique in the workspace. | | `isDefault` | Set `true` to make this the workspace's default profile. | ## Use externalId `externalId` is how you tie a Pozy profile to a record in your system: a customer id, a tenant slug, a client code. It pays off in three places: - **Lookups.** `GET /v1/profiles?externalId=client_204` returns that profile, so you never have to store the Pozy id if you prefer not to. - **Retries.** A second create with the same `externalId` returns `409` with `code: "profile_external_id_conflict"` and `details.existingProfileId`. Treat it as "already done" and carry on with that id. - **Webhooks.** Every event lists the profiles it concerns as `profiles: [{ "id", "externalId" }]`, so your handler can route without a database query ([route webhooks](https://pozy.ai/docs/build/route-webhooks)). ```json { "error": "A profile with this externalId already exists.", "type": "invalid_request_error", "code": "profile_external_id_conflict", "details": { "existingProfileId": "6722b8e05d1f4a97c3e2b018" } } ``` You can set, change or clear `externalId` later with an update. Clearing it (`null`) frees the value for another profile. ## List and find profiles `GET /v1/profiles` returns every profile in the workspace, the default first. Filter with `name` or `externalId` (both exact matches). ```bash cURL curl "https://api.pozy.ai/v1/profiles?externalId=client_204" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/profiles?externalId=client_204", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const profile = data.profiles[0]; // undefined when there is no match ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/profiles", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"externalId": "client_204"}, ) profiles = resp.json()["profiles"] ``` A profile-scoped API key only ever sees the profiles it was given. ## Update a profile `PUT /v1/profiles/{profileId}` changes only the fields you send. It also takes `timezone`, an IANA name such as `Europe/Vienna`, stored as the profile's default timezone. ```bash Terminal curl -X PUT https://api.pozy.ai/v1/profiles/6722b8e05d1f4a97c3e2b018 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Northwind Coffee Co.", "timezone": "Europe/Vienna" }' ``` Send `"isDefault": true` to make a profile the default. The previous default loses the flag. ## The default profile There is always exactly one default profile, flagged `isDefault: true` and listed first. The dashboard opens on it. If you delete it, another profile becomes the default. Keep the default for your own brand's accounts and give each customer their own profile next to it. ## Move an account to another profile Accounts can change profile without reconnecting, for example when two clients merge or you reorganise brands. Send the target `profileId` to `PATCH /v1/accounts/{accountId}`: ```bash cURL curl -X PATCH https://api.pozy.ai/v1/accounts/6720f41a8d3be96c1e5a02f7 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6722b8e05d1f4a97c3e2b018" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6720f41a8d3be96c1e5a02f7", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6722b8e05d1f4a97c3e2b018" }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.patch( "https://api.pozy.ai/v1/accounts/6720f41a8d3be96c1e5a02f7", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"profileId": "6722b8e05d1f4a97c3e2b018"}, ) ``` With a profile-scoped key, the target profile must be in the key's scope. ## Delete a profile ```bash Terminal curl -X DELETE https://api.pozy.ai/v1/profiles/6722b8e05d1f4a97c3e2b018 \ -H "Authorization: Bearer $POZY_API_KEY" ``` - A profile that still has active connected accounts cannot be deleted. Disconnect them first with `DELETE /v1/accounts/{accountId}`. - Disconnected accounts and WhatsApp numbers still in the profile move to another of your profiles. They are never deleted along with it. - Deleting is permanent. The `externalId` becomes free for reuse. - API keys scoped to only this profile stop being useful. Revoke them ([tenant keys](https://pozy.ai/docs/build/tenant-keys)). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/profiles`](https://pozy.ai/docs/api/listProfiles) | List profiles | | [`POST /v1/profiles`](https://pozy.ai/docs/api/createProfile) | Create profile | | [`GET /v1/profiles/{profileId}`](https://pozy.ai/docs/api/getProfile) | Get profile | | [`PUT /v1/profiles/{profileId}`](https://pozy.ai/docs/api/updateProfile) | Update profile | | [`DELETE /v1/profiles/{profileId}`](https://pozy.ai/docs/api/deleteProfile) | Delete profile | --- # Per-customer API keys Issue an API key per customer that only reaches their profile, make it read-only or bar whole areas of the API, and rotate it without downtime. Your main backend can use one full-access key for every customer. Some parts of your system should not: a worker that runs customer-supplied automations, a reporting export, an AI agent acting for one tenant, a script you hand to an agency. Give those a key scoped to one customer's profile, so the worst a bug or a leak can do is limited to that customer. ## Create a key for one customer Call `POST /v1/api-keys` with `scope: "profiles"` and the customer's profile id. Use your full-access, read-write key for this call: scoped and read-only keys cannot create keys. ```bash cURL curl -X POST https://api.pozy.ai/v1/api-keys \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "cus_8f3a2 scheduler", "scope": "profiles", "profileIds": ["6720f3b1c9e4a85d2f0b7c14"], "disabledResourceGroups": ["ads", "telephony", "money", "billing", "webhooks", "commerce", "creator_campaigns"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/api-keys", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "cus_8f3a2 scheduler", scope: "profiles", profileIds: [customer.pozyProfileId], disabledResourceGroups: ["ads", "telephony", "money", "billing", "webhooks", "commerce", "creator_campaigns"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); await secrets.put(`pozy/${customer.id}`, data.apiKey.key); // shown once ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/api-keys", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "cus_8f3a2 scheduler", "scope": "profiles", "profileIds": ["6720f3b1c9e4a85d2f0b7c14"], "disabledResourceGroups": ["ads", "telephony", "money", "billing", "webhooks", "commerce", "creator_campaigns"], }, ) key = resp.json()["apiKey"]["key"] # shown once: store it in your secret manager ``` Store the returned `key` in your secret manager straight away. It is never shown again; lists only include `keyPreview`. A key can cover several profiles. A customer with two brands can get one key with both profile ids. ## Pick the right restrictions Combine the three controls to match the job: | Job | `scope` | `permission` | `disabledResourceGroups` | | --- | --- | --- | --- | | Post scheduler for one customer | `profiles` | `read-write` | everything except `publishing` and `accounts` | | Customer-facing analytics dashboard | `profiles` | `read` | `messages`, `contacts`, `billing`, `webhooks`, `money` | | Support inbox for one customer | `profiles` | `read-write` | everything except `messages`, `engagement`, `contacts`, `accounts` | | AI assistant that only reads | `profiles` | `read` | `billing`, `money`, `webhooks` | | Short-lived access for a contractor | `profiles` | as needed | as needed, plus `expiresIn: 7` | The full list of groups and what each covers is in [Authentication](https://pozy.ai/docs/getting-started/authentication#disabled-resource-groups). ## What a scoped key can and cannot do A profile-scoped key: - Can do anything its permission and groups allow, as long as every `profileId` in the request is in its scope. - Gets `403` with `code: "profile_out_of_scope"` when a request names any other profile. - Gets `403` with `code: "account_out_of_scope"` when a request names an account that lives in any other profile, for example an `accountId` in a post's `platforms`. - Sees only its own profiles in `GET /v1/profiles`. - Can move an account only into a profile in its scope. - Cannot list, create or delete API keys (`403`, `code: "insufficient_permissions"`). - Cannot read workspace-wide billing or usage (`403`, `code: "profile_scoped_key"`), but can read usage for its own profile with `GET /v1/usage?profileId=...`. > **Always pass profileId on lists:** > > Pass the customer's `profileId` on every list call made with a scoped key, such as accounts, posts, conversations and analytics. It keeps results inside the tenant and makes the scope check explicit. ## Rate limits are shared Scoped keys do not add throughput. Every key in the workspace draws from the same per-minute budget ([rate limits](https://pozy.ai/docs/getting-started/rate-limits)). If one customer runs a large import, queue it on your side so it cannot use up the budget your other customers need. ## Rotate a customer's key There is no update endpoint for keys. To change a key's settings or rotate it: 1. Create a new key with the settings you want. 2. Switch the customer's service to it. 3. Delete the old key with `DELETE /v1/api-keys/{keyId}`. Deleting takes effect on the next request. Keep the key `id` next to the stored secret so you can revoke the right one later. ## Clean up When a customer leaves, revoke their keys as part of offboarding ([build a platform](https://pozy.ai/docs/build#offboard-a-customer)). Use `GET /v1/api-keys` to find keys whose `profileIds` include the profile you are deleting. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/auth/verify`](https://pozy.ai/docs/api/verifyCredential) | Verify credential | | [`GET /v1/api-keys`](https://pozy.ai/docs/api/listApiKeys) | List keys | | [`POST /v1/api-keys`](https://pozy.ai/docs/api/createApiKey) | Create key | | [`DELETE /v1/api-keys/{keyId}`](https://pozy.ai/docs/api/deleteApiKey) | Delete key | --- # Route webhooks to tenants Use the profiles array on every event to send each webhook to the right customer, filter endpoints by profile or account, and fan out safely. When many customers share your workspace, every event has to find its way back to the right one. Pozy makes that a field lookup: each event lists the profiles it concerns, with the `externalId` you gave them. ## The profiles array Every event body carries `profiles`: ```json { "id": "evt_5b0e9c3a71f2d846", "event": "message.received", "timestamp": "2026-10-30T16:41:09.000Z", "profiles": [{ "id": "6720f3b1c9e4a85d2f0b7c14", "externalId": "cus_8f3a2" }], "message": { "...": "..." } } ``` - `id` is the Pozy profile id. `externalId` is yours, or `null` if you never set one. - Most events concern one profile. A post that targets accounts in several profiles lists each of them. - Workspace-level events can arrive with an empty array. Route those to your own operations channel. Set `externalId` on every customer profile ([profiles](https://pozy.ai/docs/build/profiles)) and you never need a database query to route an event. ## Pattern 1: one endpoint, route in your code Register a single endpoint for all customers and route on `externalId`. This is the simplest setup and scales to any number of customers. ```ts import { createServer } from "node:http"; import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.POZY_WEBHOOK_SECRET; const seen = new Set(); // use Redis or your database in production function verified(raw, signature) { const expected = createHmac("sha256", SECRET).update(raw).digest("hex"); return typeof signature === "string" && signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } createServer((req, res) => { let raw = ""; req.on("data", (c) => (raw += c)); req.on("end", async () => { if (!verified(raw, req.headers["x-pozy-signature"])) return res.writeHead(401).end(); const eventId = req.headers["x-pozy-event-id"]; if (seen.has(eventId)) return res.writeHead(200).end(); // duplicate delivery seen.add(eventId); const event = JSON.parse(raw); const tenants = (event.profiles ?? []).map((p) => p.externalId).filter(Boolean); // Acknowledge fast, work later: Pozy waits 5 seconds at most. res.writeHead(200).end(); if (tenants.length === 0) await queue.push("ops", event); for (const tenant of tenants) await queue.push(`tenant:${tenant}`, event); }); }).listen(8080); ``` Follow three rules: 1. **Verify first.** Reject anything whose `X-Pozy-Signature` does not match ([verify signatures](https://pozy.ai/docs/webhooks/signatures)). 2. **Dedupe on `X-Pozy-Event-Id`.** A delivery can arrive more than once, for example when your server timed out but had already processed it. The header equals the body's `id`. 3. **Answer within 5 seconds.** Push the event to a per-tenant queue and return `200`. Slow handlers cause retries. ## Pattern 2: one endpoint per customer When a customer wants events delivered to their own URL, give them an endpoint filtered to their profile, with its own secret: ```bash cURL curl -X POST https://api.pozy.ai/v1/webhooks/settings \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "cus_8f3a2", "url": "https://hooks.acme.example/pozy", "secret": "9c2f6b8e1d4a7f3c5e0b2d9a6c8f1e4b", "events": ["post.published", "post.failed", "account.disconnected"], "profileIds": ["6720f3b1c9e4a85d2f0b7c14"], "customHeaders": { "X-Tenant": "cus_8f3a2" } }' ``` ```ts Node import { randomBytes } from "node:crypto"; const secret = randomBytes(32).toString("hex"); const res = await fetch("https://api.pozy.ai/v1/webhooks/settings", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: customer.id, url: customer.webhookUrl, secret, events: ["post.published", "post.failed", "account.disconnected"], profileIds: [customer.pozyProfileId], customHeaders: { "X-Tenant": customer.id }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // show `secret` to the customer once so they can verify deliveries ``` ```python Python import os, secrets, requests secret = secrets.token_hex(32) resp = requests.post( "https://api.pozy.ai/v1/webhooks/settings", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "cus_8f3a2", "url": "https://hooks.acme.example/pozy", "secret": secret, "events": ["post.published", "post.failed", "account.disconnected"], "profileIds": ["6720f3b1c9e4a85d2f0b7c14"], "customHeaders": {"X-Tenant": "cus_8f3a2"}, }, ) ``` A workspace can have up to 50 endpoints. Beyond that, use pattern 1 and forward to customer URLs from your own queue (pattern 3). ## Endpoint filters An event is delivered to an endpoint only when it passes every filter the endpoint has: | Filter | Empty means | Set means | | --- | --- | --- | | `events` | (required, at least one) | Only these event types. | | `profileIds` | Every profile | Only events whose `profiles` include one of these ids. | | `accountIds` | Every account | Only events about one of these connected accounts. | `customHeaders` are added to every delivery to that endpoint. They are useful for routing at a gateway, but they are not a substitute for checking the signature. Change filters later with `PUT /v1/webhooks/settings`, passing the endpoint's `webhookId` and the fields to change. ## Pattern 3: fan out from your own queue For many customers with their own URLs, or for strict per-customer isolation, receive everything on one endpoint (pattern 1), then deliver to each customer from your own infrastructure. You control retries, per-customer rate limits and the signing secret your customers verify, and a slow customer endpoint can never delay events for the others. ## Account events tie it together Two events keep your tenant records in step with what customers connect: - `account.connected`: store `account.accountId` on the customer named by `profiles[0].externalId`. - `account.disconnected`: mark that account as needing attention and prompt the customer to reconnect. | Event | When it fires | | --- | --- | | [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. | | [`account.rate_limited`](https://pozy.ai/docs/webhooks/events#account-rate-limited) | Mailbox sending cooldown started. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`account.permissions_changed`](https://pozy.ai/docs/webhooks/events#account-permissions-changed) | Account permissions changed. | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | ## Testing a route `POST /v1/webhooks/test` sends a sample event to one endpoint so you can watch it reach the right queue. Test deliveries carry `"test": true` in the body; ignore them in production handlers. Delivery attempts, response codes and bodies are kept in `GET /v1/webhooks/logs`, filterable by endpoint, event and status ([test webhooks](https://pozy.ai/docs/webhooks/testing)). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/webhooks/settings`](https://pozy.ai/docs/api/getWebhookSettings) | List webhooks | | [`POST /v1/webhooks/settings`](https://pozy.ai/docs/api/createWebhookSettings) | Create webhook | | [`PUT /v1/webhooks/settings`](https://pozy.ai/docs/api/updateWebhookSettings) | Update webhook | | [`DELETE /v1/webhooks/settings`](https://pozy.ai/docs/api/deleteWebhookSettings) | Delete webhook | | [`GET /v1/webhooks/logs`](https://pozy.ai/docs/api/getWebhookLogs) | List webhook delivery logs | | [`POST /v1/webhooks/logs/redeliver`](https://pozy.ai/docs/api/redeliverWebhookEvent) | Redeliver a webhook event | | [`POST /v1/webhooks/test`](https://pozy.ai/docs/api/testWebhook) | Send test webhook | --- # Connecting accounts How social, messaging, ads, store and workspace accounts get into Pozy, and how to keep them connected. An account is one connected identity on a platform: an Instagram profile, a Facebook Page, a Slack channel, a Shopify store, a Gmail mailbox. Every account lives inside a [profile](https://pozy.ai/docs/build/profiles). You connect it once, then post, read and reply through its `accountId`. This page explains the connect methods, the flow most platforms use, and what to do after an account is connected. ## Connect methods Each platform uses one of four methods. The method decides whether the user leaves your app and what you have to collect. | Method | What happens | Platforms | | --- | --- | --- | | OAuth | You request a connect URL, the user approves on the platform, and Pozy sends them back to your `redirect_url`. | Instagram, Facebook, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Google Business Profile, Snapchat, Google, Microsoft, HubSpot, Shopify, WordPress.com, Wix, Webflow, Google Analytics and the ad networks | | Credentials | You send a token or app password the user created on the platform. No redirect. | Bluesky, self-hosted WordPress and WooCommerce, Webflow site tokens, Shopify custom apps, WhatsApp system user tokens, ChatGPT Ads | | Bot install | The user adds Pozy's bot to a workspace, then picks the channels it posts to. | Slack. Discord and Telegram are coming soon | | Phone number | You buy or port a number in Pozy, and it becomes an account. | SMS, RCS, iMessage, WhatsApp numbers bought in Pozy | [Credentials and bots](https://pozy.ai/docs/connect/credentials) covers the second and third rows. Phone numbers are covered in [Phone, SMS & voice](https://pozy.ai/docs/telephony). | Platform | Publish | Analytics | DMs | Comments | Reviews | Connect | | --- | --- | --- | --- | --- | --- | --- | | Instagram | Yes | Yes | Yes | Yes | – | OAuth | | TikTok | Yes | Yes | Yes | Yes | – | OAuth | | YouTube | Yes | Yes | – | Yes | – | OAuth | | Facebook | Yes | Yes | Yes | Yes | Yes | OAuth | | LinkedIn | Yes | Yes | – | Yes | – | OAuth | | X | Yes | Yes | Yes | Yes | – | OAuth | | Threads | Yes | Yes | – | Yes | – | OAuth | | Pinterest | Yes | Yes | – | – | – | OAuth | | Bluesky | Yes | Yes | Yes | Yes | – | Credentials | | Google Business Profile | Yes | Yes | – | – | Yes | OAuth | | Snapchat (beta) | Yes | Yes | – | – | – | OAuth | | Reddit (paused) | Yes | Yes | Yes | Yes | – | OAuth | | WhatsApp | Yes | – | Yes | – | – | Credentials | | Telegram (coming soon) | Yes | – | Yes | – | – | Bot token | | Discord (coming soon) | Yes | – | – | – | – | Bot token | | Slack | Yes | – | Yes | – | – | Bot token | | iMessage | – | – | Yes | – | – | Phone number | | SMS | – | – | Yes | – | – | Phone number | | RCS | – | – | Yes | – | – | Phone number | | Meta Ads | – | – | – | – | – | OAuth | | Google Ads | – | – | – | – | – | OAuth | | TikTok Ads | – | – | – | – | – | OAuth | | LinkedIn Ads | – | – | – | – | – | OAuth | | Pinterest Ads | – | – | – | – | – | OAuth | | X Ads | – | – | – | – | – | OAuth | | ChatGPT Ads | – | – | – | – | – | API token | | Reddit Ads | – | – | – | – | – | OAuth | | Whop Ads | – | – | – | – | – | API token | | Shopify | – | – | – | – | – | OAuth | | WooCommerce | – | – | – | – | – | Credentials | | WordPress | – | – | – | – | – | OAuth | | Wix | – | – | – | – | – | OAuth | | Webflow | – | – | – | – | – | API token | | HubSpot | – | – | – | – | – | OAuth | | Google Analytics | – | – | – | – | – | OAuth | | Google Workspace | – | – | – | – | – | OAuth | | Microsoft 365 | – | – | – | – | – | OAuth | ## The OAuth flow Most connects follow the same five steps, whatever the platform. ### Request a connect URL Call `GET /v1/connect/{platform}` with the `profileId` the account should join and the `redirect_url` the user returns to. ```bash cURL curl "https://api.pozy.ai/v1/connect/linkedin?profileId=6650c1f2a9e4b7d3c8a1f0e2&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/linkedin?profileId=6650c1f2a9e4b7d3c8a1f0e2&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Send the user's browser to data.authUrl ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/connect/linkedin", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1f2a9e4b7d3c8a1f0e2", "redirect_url": "https://app.example.com/connected"}, timeout=30, ) auth_url = resp.json()["authUrl"] ``` ### Send the user to `authUrl` Redirect the browser, or open it in a new tab or an in-app browser. The link works for 30 minutes. ### The user approves on the platform They sign in on the platform and approve the permissions Pozy asks for. You can [ask for fewer permissions](https://pozy.ai/docs/connect/scopes) with `scopes`. ### The user picks a Page, location or board Facebook, Instagram (with Facebook Login), LinkedIn, Pinterest, Google Business Profile, Snapchat, WhatsApp, Slack and Google Analytics end with a choice. Pozy shows its own picker, or you build one in [headless mode](https://pozy.ai/docs/connect/headless). ### Pozy redirects back The browser lands on your `redirect_url` with the result in the query string: ```text https://app.example.com/connected?connected=linkedin&profileId=6650c1f2a9e4b7d3c8a1f0e2&accountId=6651d3a7b2c9e4f1a0b8c7d6&username=acme&stage=callback&request_id=req_... ``` Store `accountId`. It is the id every other endpoint takes. The [hosted connect pages](https://pozy.ai/docs/connect/hosted-connect) guide covers every parameter, the success and error redirects, and what your users see along the way. ## After an account is connected | Job | Call | | --- | --- | | List a profile's accounts | `GET /v1/accounts?profileId=...` ([listAccounts](https://pozy.ai/docs/api/listAccounts)) | | Check tokens and permissions | `GET /v1/accounts/{accountId}/health` ([account health](https://pozy.ai/docs/connect/account-health)) | | Reconnect the same account | `GET /v1/connect/{platform}` with `reconnectAccountId` | | Move it to another profile | `PATCH /v1/accounts/{accountId}` with `{ "profileId": "..." }` ([moveAccountToProfile](https://pozy.ai/docs/api/moveAccountToProfile)) | | Disconnect it | `DELETE /v1/accounts/{accountId}` ([deleteAccount](https://pozy.ai/docs/api/deleteAccount)) | A profile can hold several accounts on the same platform. Connecting a different account adds a new one. Connecting the same account again keeps its `accountId` and its history, so a reconnect never breaks your stored ids. Ads connections are the exception: one per platform per profile. > **Listen instead of polling:** > > Subscribe to `account.connected` and `account.disconnected`. A connect started in one tab and finished on a phone still reaches your backend, and you hear about expired tokens without checking every account. ## Billing gates Connecting an account can return `402 payment_required` when: | Reason | What it means | | --- | --- | | `free_tier_exceeded` | The workspace already has its free accounts and no payment method. | | `twitter_passthrough` | X bills every API call, so connecting any X account needs a payment method. | | `enterprise_required` | The workspace hit a cap set in its contract. | The response carries a `dashboard_url` that opens billing in the dashboard. Send the workspace owner there. See [Pricing](https://pozy.ai/docs/billing) for what each account costs. ## Webhook events | Event | When it fires | | --- | --- | | [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. | | [`account.rate_limited`](https://pozy.ai/docs/webhooks/events#account-rate-limited) | Mailbox sending cooldown started. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`account.permissions_changed`](https://pozy.ai/docs/webhooks/events#account-permissions-changed) | Account permissions changed. | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | ## In this section - [Hosted connect pages](https://pozy.ai/docs/connect/hosted-connect): Connect URLs, redirects, results and errors. - [Headless selection](https://pozy.ai/docs/connect/headless): Build your own Page, location and board picker. - [Credentials and bots](https://pozy.ai/docs/connect/credentials): Bluesky, WordPress, Webflow, Shopify, WhatsApp and Slack. - [Welcome screen](https://pozy.ai/docs/connect/welcome-screen): Show your brand before the platform's consent screen. - [Phone handoff](https://pozy.ai/docs/connect/phone-handoff): Let desktop users finish on their phone with a QR code. - [Scopes](https://pozy.ai/docs/connect/scopes): Ask only for the permissions your product uses. - [Your own platform apps](https://pozy.ai/docs/connect/own-apps): Coming soon. Put your app's name on the consent screen. - [Account health](https://pozy.ai/docs/connect/account-health): Tokens, permissions, sync status and reconnects. - [Account groups](https://pozy.ai/docs/connect/account-groups): Save sets of accounts you post to together. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/connect/handoffs/{handoffId}`](https://pozy.ai/docs/api/getConnectHandoff) | Get a phone handoff | | [`GET /v1/connect/pending-data`](https://pozy.ai/docs/api/getPendingOAuthData) | Get pending OAuth data | | [`GET /v1/accounts`](https://pozy.ai/docs/api/listAccounts) | List accounts | | [`GET /v1/accounts/health`](https://pozy.ai/docs/api/getAllAccountsHealth) | Check accounts health | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | | [`PUT /v1/accounts/{accountId}`](https://pozy.ai/docs/api/updateAccount) | Update account | | [`PATCH /v1/accounts/{accountId}`](https://pozy.ai/docs/api/moveAccountToProfile) | Move account to another profile | | [`DELETE /v1/accounts/{accountId}`](https://pozy.ai/docs/api/deleteAccount) | Disconnect account | Every connect endpoint, including each platform's selection and credential calls, is listed under **Connect** in the [API reference](https://pozy.ai/docs/api#connect). --- # Hosted connect pages Start an OAuth connect with one API call, send the user through Pozy's hosted pages and read the result on your redirect URL. Hosted connect is the quickest way to get a user's account into Pozy. You make one API call, send the user's browser to the URL it returns, and Pozy handles the platform's consent screen, any Page or location picker, and the redirect back to your app. The hosted pages run on `pozy.ai`. Add a [welcome screen](https://pozy.ai/docs/connect/welcome-screen) and they carry your name and logo instead of Pozy's. ## Start a connect `GET /v1/connect/{platform}` returns an `authUrl`. `profileId` is required: it is the profile the new account joins. ```bash cURL curl "https://api.pozy.ai/v1/connect/instagram?profileId=6650c1f2a9e4b7d3c8a1f0e2&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/instagram?profileId=6650c1f2a9e4b7d3c8a1f0e2&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // In an Express handler: res.redirect(data.authUrl) ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/connect/instagram", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "redirect_url": "https://app.example.com/connected", }, timeout=30, ) resp.raise_for_status() auth_url = resp.json()["authUrl"] ``` ```json { "authUrl": "https://www.instagram.com/oauth/authorize?client_id=...", "state": "c2Vzc2lvbi0..." } ``` Send the browser to `authUrl`. Keep `state` if you want to match the callback to the request, but treat it as opaque. The link and the flow behind it expire after 30 minutes. Call this endpoint from your server. The key in the `Authorization` header must never reach the browser. ## Platforms `{platform}` is one of `facebook`, `instagram`, `linkedin`, `twitter` (X), `tiktok`, `youtube`, `threads`, `pinterest`, `googlebusiness`, `snapchat`, `reddit`, `slack`, `whatsapp`, `google` and `microsoft`. A few platforms have their own start endpoint because they need extra input or behave differently: | Platform | Start with | Notes | | --- | --- | --- | | Shopify | `GET /v1/connect/shopify?shop=your-store.myshopify.com` | The store domain is required. | | WordPress.com | `GET /v1/connect/wordpress` | Self-hosted sites use an [application password](https://pozy.ai/docs/connect/credentials#wordpress-and-woocommerce). | | Wix | `GET /v1/connect/wix` | Returns the Wix app install link. | | Webflow | `GET /v1/connect/webflow` | The user picks exactly one site. | | HubSpot | `GET /v1/connect/hubspot` | One portal per profile. | | Google Analytics | `GET /v1/connect/googleanalytics` | Each GA4 property becomes its own account. | | Ad networks | `GET /v1/connect/{platform}/ads` | Meta, LinkedIn, TikTok, X, Pinterest, Google Ads and Reddit Ads. See [Ads](https://pozy.ai/docs/ads). | | Bluesky | No OAuth | See [Credentials and bots](https://pozy.ai/docs/connect/credentials). | | Telegram, Discord | Coming soon | Not available to connect yet: the connect URL answers `403 feature_not_available`. | ## Parameters | Parameter | What it does | | --- | --- | | `profileId` | Required. The profile the account joins. A profile-scoped key can only use its own profiles. | | `redirect_url` | Where the browser goes when the flow ends. An absolute `https://` URL, plain `http://` only for `localhost` (for local development), or an app scheme such as `myapp://connected`. Your own query parameters are kept. | | `scopes` | Comma-separated permission areas, for example `posting,analytics`. See [Scopes](https://pozy.ai/docs/connect/scopes). | | `reconnectAccountId` | Reconnect this existing account. Its `accountId` and history stay the same. | | `oauthAppId` | Run the connect through [your own platform app](https://pozy.ai/docs/connect/own-apps). Coming soon. | | `loginHint` | An email address to preselect in the Google or Microsoft account picker. | | `loginMethod` | Instagram only: `instagram_login` (default) or `facebook_login`. Facebook Login adds a Page picker. | | `headless` | `true` to skip Pozy's picker and build your own. See [Headless selection](https://pozy.ai/docs/connect/headless). | | `welcome`, `brandName`, `logoUrl`, `primaryColor`, `welcomeTheme` | Show and style the [welcome screen](https://pozy.ai/docs/connect/welcome-screen). | | `handoff` | `true` to get a short link for a QR code. See [Phone handoff](https://pozy.ai/docs/connect/phone-handoff). | The full list, with types and limits, is on [Get OAuth connect URL](https://pozy.ai/docs/api/getConnectUrl). ## What the user sees 1. **Welcome screen** (optional): "Acme wants to connect to your Instagram", with your logo. It forwards to the platform after about a second. 2. **The platform's consent screen**: sign in, review the permissions, approve. 3. **Picker** (some platforms): choose the Facebook Pages, LinkedIn page, Pinterest board, business locations, WhatsApp number or Slack channels to connect. Several can be ticked where the platform allows it, and each becomes its own account. 4. **Back to your app**: the browser goes to `redirect_url`. With no `redirect_url`, the user sees a "You're connected" screen they can close. Connects started from the Pozy dashboard return to **Connections** in the dashboard instead. ## Read the result On success, Pozy adds these parameters to your `redirect_url`: | Parameter | Value | | --- | --- | | `connected` | The platform id, for example `instagram`. | | `profileId` | The profile the account joined. | | `accountId` | The new or reconnected account. Store this. | | `username` | The account's handle, Page name, store domain or mailbox address. | | `stage` | Where the flow ended: `callback`, or the picker step such as `select_page`. | | `request_id` | Quote it to support if something looks wrong. | When a picker connects several accounts at once, the redirect names the first one. List the profile's accounts, or wait for one `account.connected` event per account, to get them all. ```ts app.get("/connected", async (req, res) => { const { connected, accountId, profileId, error, error_message } = req.query; if (error) { return res.redirect(`/settings/channels?failed=${encodeURIComponent(String(error_message ?? error))}`); } await db.channels.upsert({ accountId, profileId, platform: connected }); res.redirect("/settings/channels?connected=1"); }); ``` > **Do not trust the redirect alone:** > > Anyone can type a URL with `accountId=...` into a browser. Before you link an account to a user in your database, confirm it: list that user's profile with `GET /v1/accounts?profileId=...` and check the account is in it, or wait for the `account.connected` webhook and check that its `profileId` belongs to that user. ## Errors on the redirect A cancelled or failed connect sends the browser to `redirect_url` with `error` and `platform`, plus `error_message` you can show the user when one is available. ```text https://app.example.com/connected?error=oauth_denied&platform=instagram&is_user_fixable=true&stage=authorize&request_id=req_... ``` | `error` | Cause | What to do | | --- | --- | --- | | `oauth_denied` | The user cancelled or declined a permission. `platform_error` and `platform_error_description` carry the platform's own reason when it sends one. | Let them try again. | | `no_facebook_pages` | No Facebook Page was shared. `error_reason` says why: `pages_permission_declined`, `no_pages_granted` or `granted_pages_not_listed`. | Connect again, choose "Edit previous settings" in Meta's dialog and tick a Page. | | `invalid_state` | The flow took longer than 30 minutes or the link was used twice. | Request a fresh connect URL. | | `connection_failed` | The platform refused the authorization code. | Start again. | | `payment_required` | Billing blocks the connect. | Send the workspace owner to billing. | | `temporarily_unavailable` | A platform service Pozy relies on cannot take new connections right now. Nothing is wrong on your side. | Try again later. | | `reconnect_required` | This account must be reconnected from its own settings. | Use `reconnectAccountId`. | `is_user_fixable=true` means connecting again usually fixes it. Treat any `error` you do not recognise as a generic failure: new values can appear. Errors that happen before the user leaves your app come back as a normal API error instead: | Status | `code` | Cause | | --- | --- | --- | | 400 | `INVALID_REDIRECT_URL` | `redirect_url` is relative, or uses plain `http://` for a host other than `localhost`. Use an `https://` URL or an app scheme. | | 400 | `missing_field` | `profileId` is missing. | | 400 | `invalid_field_value` | A bad `scopes` value, or welcome and handoff options that cannot be combined. `param` names the field. | | 402 | `payment_required` | See [billing gates](https://pozy.ai/docs/connect#billing-gates). | | 404 | `profile_not_found` | The profile does not exist in this workspace. | | 503 | `temporarily_unavailable` | A platform service Pozy relies on cannot take new connections right now. Retry later. | ## Reconnect an account Tokens expire and users revoke access. To repair an account, start a connect for the same platform with `reconnectAccountId`: ```bash curl "https://api.pozy.ai/v1/connect/instagram?profileId=6650c1f2a9e4b7d3c8a1f0e2&reconnectAccountId=6651d3a7b2c9e4f1a0b8c7d6&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` The account keeps its id, its posts and its analytics history. [Account health](https://pozy.ai/docs/connect/account-health) tells you which accounts need it. ## White-label notes If your customers connect their own accounts through your product: - Create one [profile per customer](https://pozy.ai/docs/build/profiles) and pass that `profileId`. Accounts never mix between customers. - Turn on the [welcome screen](https://pozy.ai/docs/connect/welcome-screen) with your brand, so the first thing they see is your name. - [Your own platform apps](https://pozy.ai/docs/connect/own-apps), coming soon, will put your app's name on the platform's consent screen too. - Route `account.connected` to the right customer with the event's `profiles[].externalId`. See [Route webhooks to tenants](https://pozy.ai/docs/build/route-webhooks). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/connect/shopify`](https://pozy.ai/docs/api/getShopifyConnectUrl) | Get Shopify OAuth connect URL | | [`GET /v1/connect/wordpress`](https://pozy.ai/docs/api/getWordPressAuthUrl) | Get WordPress.com OAuth connect URL | | [`GET /v1/connect/wix`](https://pozy.ai/docs/api/getWixConnectUrl) | Get Wix site install URL | | [`GET /v1/connect/webflow`](https://pozy.ai/docs/api/getWebflowConnectUrl) | Get Webflow OAuth URL | | [`GET /v1/connect/hubspot`](https://pozy.ai/docs/api/getHubSpotConnectUrl) | Get HubSpot OAuth connect URL | | [`GET /v1/connect/googleanalytics`](https://pozy.ai/docs/api/getGoogleAnalyticsConnectUrl) | Get Google Analytics OAuth connect URL | | [`GET /v1/accounts`](https://pozy.ai/docs/api/listAccounts) | List accounts | --- # Headless selection Build your own picker for Facebook Pages, Instagram accounts, LinkedIn pages, Pinterest boards, business locations and more, after the user approves on the platform. Some platforms grant access to a person, not to the thing you want to post as. After a Facebook login you still need to know which Page; after a Google login, which business location. By default Pozy shows a picker for this step. In headless mode you build the picker in your own app and Pozy only handles the OAuth around it. Use headless mode when the connect has to look native inside your product, when you want to filter the choices (for example, only locations in one country), or when you connect accounts on behalf of users who never see a Pozy page. ## Platforms with a selection step | Platform | `step` on the callback | The user chooses | List with | Connect with | | --- | --- | --- | --- | --- | | Facebook | `select_page` | One or more Pages | [`GET /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/listFacebookPages) | [`POST /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/selectFacebookPage) | | Instagram (with `loginMethod=facebook_login`) | `select_account` | Pages with a linked Instagram professional account | [`GET /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/listInstagramPages) | [`POST /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/selectInstagramAccount) | | LinkedIn | `select_organization` | Their personal profile or company pages they administer | [`GET /v1/connect/pending-data`](https://pozy.ai/docs/api/getPendingOAuthData), then [`GET /v1/connect/linkedin/organizations`](https://pozy.ai/docs/api/listLinkedInOrganizations) for logos | [`POST /v1/connect/linkedin/select-organization`](https://pozy.ai/docs/api/selectLinkedInOrganization) | | Pinterest | `select_board` | A default board | [`GET /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/listPinterestBoardsForSelection) | [`POST /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/selectPinterestBoard) | | Google Business Profile | `select_location` | One or more locations | [`GET /v1/connect/googlebusiness/locations`](https://pozy.ai/docs/api/listGoogleBusinessLocations) | [`POST /v1/connect/googlebusiness/select-location`](https://pozy.ai/docs/api/selectGoogleBusinessLocation) | | Snapchat | `select_public_profile` | A Public Profile | [`GET /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/listSnapchatProfiles) | [`POST /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/selectSnapchatProfile) | | WhatsApp | `select_phone_number` | A number in a WhatsApp Business Account | [`GET /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/listWhatsAppPhoneNumbers) | [`POST /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/completeWhatsAppPhoneSelection) | | Slack | `select_channel` | One or more channels | [`GET /v1/connect/slack`](https://pozy.ai/docs/api/listSlackChannels) with `pendingDataToken` | [`POST /v1/connect/slack`](https://pozy.ai/docs/api/connectSlackChannel) | | Google Analytics | `select_property` | A GA4 property | [`GET /v1/connect/googleanalytics/properties`](https://pozy.ai/docs/api/listGoogleAnalyticsProperties) | [`POST /v1/connect/googleanalytics/select-property`](https://pozy.ai/docs/api/selectGoogleAnalyticsProperty) | The default Instagram login (`instagram_login`) has no selection step. ## How the flow works ### Start with `headless=true` Add `headless=true` and a `redirect_url` to the connect call. Headless mode needs a `redirect_url`: without one, Pozy falls back to its own picker. It cannot be combined with `welcome=true` or `handoff=true`. ```bash curl "https://api.pozy.ai/v1/connect/facebook?profileId=6650c1f2a9e4b7d3c8a1f0e2&headless=true&redirect_url=https://app.example.com/connect/facebook" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Send the browser to the returned `authUrl`. ### Read the callback After consent, the browser lands on your `redirect_url` with the data your picker needs instead of a finished account: ```text https://app.example.com/connect/facebook?profileId=6650c1f2a9e4b7d3c8a1f0e2&platform=facebook&step=select_page&tempToken=...&userProfile=...&connect_token=... ``` | Parameter | What it is | | --- | --- | | `step` | Which selection to show. See the table above. | | `tempToken` | A short-lived token for this one flow. Pass it to the list and connect calls. | | `userProfile` | JSON describing the person who logged in. Pass it back unchanged on the connect call. | | `pendingDataToken` | Sent instead of the full lists when they are too long for a URL (LinkedIn, Google Business Profile, Slack, Google Analytics, and some Pinterest and Snapchat flows). | | `connect_token` | Send it as the `X-Connect-Token` header on the list and connect calls. Pinterest and Snapchat require it. | `userProfile` arrives URL-encoded JSON. Depending on your framework it may still be encoded once after the query string is parsed, so parse defensively: ```ts const raw = url.searchParams.get("userProfile") ?? "{}"; const userProfile = JSON.parse(raw.trim().startsWith("{") ? raw : decodeURIComponent(raw)); ``` ### List the options From your server, call the list endpoint for that `step` with your API key: ```bash cURL curl "https://api.pozy.ai/v1/connect/facebook/select-page?profileId=6650c1f2a9e4b7d3c8a1f0e2&tempToken=$TEMP_TOKEN" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "X-Connect-Token: $CONNECT_TOKEN" ``` ```ts Node const res = await fetch(`https://api.pozy.ai/v1/connect/facebook/select-page?${new URLSearchParams({ profileId: "6650c1f2a9e4b7d3c8a1f0e2", tempToken })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "X-Connect-Token": connectToken, }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // data.pages: [{ id, name, username, category }] ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/facebook/select-page", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "X-Connect-Token": connect_token, }, params={"profileId": "6650c1f2a9e4b7d3c8a1f0e2", "tempToken": temp_token}, timeout=30, ) pages = resp.json()["pages"] ``` ```json { "pages": [ { "id": "104857221093311", "name": "Acme Coffee", "username": "acmecoffee", "category": "Coffee shop" }, { "id": "104857221093312", "name": "Acme Coffee Madrid", "username": "acmemadrid", "category": "Cafe" } ], "truncated": false } ``` ### Show your picker Render the options however you like. Keep the `tempToken`, `userProfile` and `connect_token` in your server-side session, not in hidden form fields. ### Connect the choice Post the choice back. The response contains the new account, and a `redirect_url` (your own, with `connected`, `accountId` and the other result parameters added) you can send the browser to. ```bash cURL curl -X POST "https://api.pozy.ai/v1/connect/facebook/select-page" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "X-Connect-Token: $CONNECT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "pageIds": ["104857221093311", "104857221093312"], "tempToken": "'"$TEMP_TOKEN"'", "userProfile": { "id": "10228841765", "name": "Ana Ruiz" }, "redirect_url": "https://app.example.com/connected" }' ``` ```ts Node const resultRes = await fetch("https://api.pozy.ai/v1/connect/facebook/select-page", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "X-Connect-Token": connectToken, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1f2a9e4b7d3c8a1f0e2", pageIds: ["104857221093311", "104857221093312"], tempToken, userProfile, redirect_url: "https://app.example.com/connected", }), }); const result = await resultRes.json(); if (!resultRes.ok) throw new Error(result.error); // result.accounts: one entry per connected Page; result.failed: Pages that could not connect ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/connect/facebook/select-page", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "X-Connect-Token": connect_token, }, json={ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "pageIds": ["104857221093311", "104857221093312"], "tempToken": temp_token, "userProfile": user_profile, "redirect_url": "https://app.example.com/connected", }, timeout=30, ) result = resp.json() ``` ```json { "message": "Facebook page connected successfully", "redirect_url": "https://app.example.com/connected?connected=facebook&profileId=6650c1f2a9e4b7d3c8a1f0e2&accountId=6651d3a7b2c9e4f1a0b8c7d6&stage=select_page", "account": { "accountId": "6651d3a7b2c9e4f1a0b8c7d6", "platform": "facebook", "username": "acmecoffee", "isActive": true, "selectedPageName": "Acme Coffee" }, "accounts": [ { "accountId": "6651d3a7b2c9e4f1a0b8c7d6", "platform": "facebook", "username": "acmecoffee" }, { "accountId": "6651d3a7b2c9e4f1a0b8c7d7", "platform": "facebook", "username": "acmemadrid" } ], "failed": [] } ``` ## Several choices at once Facebook Pages, Instagram accounts, LinkedIn pages, business locations and Slack channels can be connected in one call. Each choice becomes its own account. | Platform | Single | Several | | --- | --- | --- | | Facebook, Instagram | `pageId` | `pageIds` | | LinkedIn | `accountType` + `selectedOrganization` | `selections`: a list of `{ accountType, selectedOrganization }` | | Google Business Profile | `locationId` | `locations`: a list of `{ locationId }` | | Slack | `channelId` | `channelIds` | The response lists what connected in `accounts` and what did not in `failed`, each with a `code` and `message`. One failed Page does not stop the others. ## LinkedIn: personal profile or company page LinkedIn returns a `pendingDataToken`. Read the organizations with `GET /v1/connect/pending-data?token=...`, then fetch logos and vanity names for them with `GET /v1/connect/linkedin/organizations?tempToken=...&orgIds=...`. Offer the user's personal profile as the first option. ```json { "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "tempToken": "...", "userProfile": { "id": "Xk3pq9", "name": "Ana Ruiz" }, "selections": [ { "accountType": "personal" }, { "accountType": "organization", "selectedOrganization": { "id": "90412233", "urn": "urn:li:organization:90412233", "name": "Acme Coffee" } } ], "redirect_url": "https://app.example.com/connected" } ``` ## Timing | Token | Lifetime | | --- | --- | | The whole connect flow | 30 minutes from the connect call | | `pendingDataToken` | Readable for 1 hour, as often as you like, until the selection completes | After the selection completes, the tokens stop working. If the user abandons your picker, start a new connect. ## Change a selection later You do not need a new OAuth to switch the Page, organization, board or location behind an existing account: | Platform | Endpoint | | --- | --- | | Facebook | [`PUT /v1/accounts/{accountId}/facebook-page`](https://pozy.ai/docs/api/updateFacebookPage) with `selectedPageId` | | LinkedIn | [`PUT /v1/accounts/{accountId}/linkedin-organization`](https://pozy.ai/docs/api/updateLinkedInOrganization) | | Pinterest | [`PUT /v1/accounts/{accountId}/pinterest-boards`](https://pozy.ai/docs/api/updatePinterestBoards) | | Google Business Profile | [`PUT /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/updateGmbLocation) | Each has a matching `GET` that lists the options the account can switch to. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/pending-data`](https://pozy.ai/docs/api/getPendingOAuthData) | Get pending OAuth data | | [`GET /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/listFacebookPages) | List Facebook pages | | [`POST /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/selectFacebookPage) | Select Facebook page | | [`GET /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/listInstagramPages) | List Pages with a linked Instagram account | | [`POST /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/selectInstagramAccount) | Select the Page whose Instagram account to connect | | [`GET /v1/connect/linkedin/organizations`](https://pozy.ai/docs/api/listLinkedInOrganizations) | List LinkedIn orgs | | [`POST /v1/connect/linkedin/select-organization`](https://pozy.ai/docs/api/selectLinkedInOrganization) | Select LinkedIn org | | [`GET /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/listPinterestBoardsForSelection) | List Pinterest boards | | [`POST /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/selectPinterestBoard) | Select Pinterest board | | [`GET /v1/connect/googlebusiness/locations`](https://pozy.ai/docs/api/listGoogleBusinessLocations) | List Google Business Profile locations | | [`POST /v1/connect/googlebusiness/select-location`](https://pozy.ai/docs/api/selectGoogleBusinessLocation) | Select Google Business Profile location | | [`GET /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/listSnapchatProfiles) | List Snapchat profiles | | [`POST /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/selectSnapchatProfile) | Select Snapchat profile | | [`GET /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/listWhatsAppPhoneNumbers) | List numbers for selection | | [`POST /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/completeWhatsAppPhoneSelection) | Complete number selection | | [`GET /v1/connect/slack`](https://pozy.ai/docs/api/listSlackChannels) | List Slack channels for the channel picker | | [`POST /v1/connect/slack`](https://pozy.ai/docs/api/connectSlackChannel) | Connect a Slack channel | | [`GET /v1/connect/googleanalytics/properties`](https://pozy.ai/docs/api/listGoogleAnalyticsProperties) | List Google Analytics properties for a pending connect | | [`POST /v1/connect/googleanalytics/select-property`](https://pozy.ai/docs/api/selectGoogleAnalyticsProperty) | Connect the chosen Google Analytics property | --- # Credentials and bots Connect Bluesky, self-hosted WordPress and WooCommerce, Webflow, Shopify custom apps, WhatsApp, ChatGPT Ads and Slack without the standard OAuth redirect. Telegram and Discord are coming soon. Not every platform connects through an OAuth consent screen. Some take a token or app password the user creates on the platform. Others connect by adding Pozy's bot to a channel. This page covers each of them. > **Never ask for a main password:** > > Every credential on this page is an app-specific password or a token the user can revoke on the platform without changing their login. Never collect a user's normal account password. You do not need to keep these credentials after the connect call succeeds, so do not store them. All credential calls take the `profileId` the account joins and return the new account. Make them from your server. ## Bluesky Bluesky connects with an [app password](https://bsky.app/settings/app-passwords), created under **Settings → Privacy and security → App passwords**. It is separate from the account password and can be revoked at any time. `state` is `{userId}-{profileId}`, where `userId` is `currentUserId` from [`GET /v1/users`](https://pozy.ai/docs/api/listUsers). ```bash cURL curl -X POST "https://api.pozy.ai/v1/connect/bluesky/credentials" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "identifier": "acme.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop", "state": "usr_4f8a2c19d0b3-6650c1f2a9e4b7d3c8a1f0e2" }' ``` ```ts Node const usersRes = await fetch("https://api.pozy.ai/v1/users", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const users = await usersRes.json(); if (!usersRes.ok) throw new Error(users.error); const res = await fetch("https://api.pozy.ai/v1/connect/bluesky/credentials", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ identifier: "acme.bsky.social", appPassword: process.env.BLUESKY_APP_PASSWORD!, state: `${users.currentUserId}-6650c1f2a9e4b7d3c8a1f0e2`, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.account._id); ``` ```python Python import os import requests headers = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} users = requests.get("https://api.pozy.ai/v1/users", headers=headers, timeout=30).json() resp = requests.post( "https://api.pozy.ai/v1/connect/bluesky/credentials", headers=headers, json={ "identifier": "acme.bsky.social", "appPassword": os.environ["BLUESKY_APP_PASSWORD"], "state": f"{users['currentUserId']}-6650c1f2a9e4b7d3c8a1f0e2", }, timeout=30, ) account_id = resp.json()["account"]["_id"] ``` `identifier` is the handle (`acme.bsky.social`, or a custom domain handle) or the account's email address. ## WordPress and WooCommerce WordPress.com sites use OAuth: start with [`GET /v1/connect/wordpress`](https://pozy.ai/docs/api/getWordPressAuthUrl) and redirect the user like any [hosted connect](https://pozy.ai/docs/connect/hosted-connect). Self-hosted WordPress connects with an **application password**. The site owner creates one in WordPress admin under **Users → Profile → Application Passwords**. Then send the site URL, their username and that password: ```bash curl -X POST "https://api.pozy.ai/v1/connect/wordpress/token" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "siteUrl": "https://blog.acme.com", "username": "ana", "applicationPassword": "Ab12 Cd34 Ef56 Gh78 Ij90 Kl12" }' ``` - `siteUrl` must be the site's HTTPS address. Never put the username or password inside the URL. - Pozy checks that it can reach the REST API and that the user can publish posts and upload media. If not, the call fails and nothing is saved. - A site running WooCommerce connected this way is also a store for [Commerce](https://pozy.ai/docs/commerce). - One account is one site. It powers [Blogs](https://pozy.ai/docs/commerce/blogs), not social posts. ## Webflow Webflow supports OAuth ([`GET /v1/connect/webflow`](https://pozy.ai/docs/api/getWebflowConnectUrl)) or a site API token. The site owner generates the token in the site's settings in Webflow, then you send it: ```bash curl -X POST "https://api.pozy.ai/v1/connect/webflow/token" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "siteToken": "e1a2b3c4d5..." }' ``` A site token covers blog publishing. Installing tracking tags on the site needs the OAuth connection. Connecting the same site over OAuth later upgrades the existing account in place. ## Shopify custom apps The standard Shopify connect is OAuth with the store domain ([`GET /v1/connect/shopify?shop=...`](https://pozy.ai/docs/api/getShopifyConnectUrl)). When a merchant cannot use the browser flow, they can create a custom app in Shopify admin with the `read_content`, `write_content`, `read_products` and `write_products` scopes and give you its Admin API access token: ```bash curl -X POST "https://api.pozy.ai/v1/connect/shopify/token" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "shop": "acme-coffee.myshopify.com", "accessToken": "shpat_..." }' ``` `shop` is required here because an Admin token does not say which store it belongs to. After an OAuth connect, the store the merchant approved can differ from the `shop` you sent (merchants often type a brand name), so always key on the returned `accountId`. ## WhatsApp with a system user token The usual WhatsApp connect is Meta's Embedded Signup through `GET /v1/connect/whatsapp`, covered in [Connect WhatsApp](https://pozy.ai/docs/whatsapp). If you already manage the WhatsApp Business Account in Meta Business Manager, connect a number server to server with a system user token: ```bash curl -X POST "https://api.pozy.ai/v1/connect/whatsapp/credentials" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "accessToken": "EAAG...", "wabaId": "102938475610293", "phoneNumberId": "109283746501928", "pin": "482913" }' ``` - The token needs the `whatsapp_business_messaging` and `whatsapp_business_management` permissions on that WhatsApp Business Account. - Send `pin` when the number has two-step verification turned on. Without it, registration fails. - The response can include `registrationWarning` or `webhookNotice`. Show them to whoever set up the number: they mean the number connected but needs attention. ## ChatGPT Ads ChatGPT Ads connects with an API key from the advertiser's account: ```bash curl -X POST "https://api.pozy.ai/v1/connect/openai-ads/credentials" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "apiKey": "sk-..." }' ``` See [ChatGPT Ads](https://pozy.ai/docs/ads/chatgpt-ads) for what the connection can do. ## Telegram and Discord > **Coming soon:** > > Telegram and Discord cannot be connected yet. [`GET /v1/connect/telegram`](https://pozy.ai/docs/api/getConnectUrl), [`GET /v1/connect/discord`](https://pozy.ai/docs/api/getConnectUrl) and the Telegram access-code endpoints answer `403` with `code: "feature_not_available"`. [Telegram](https://pozy.ai/docs/platforms/telegram) and [Discord](https://pozy.ai/docs/platforms/discord) describe how they will connect once they open. ## Slack Slack installs the bot into a workspace, then the user picks channels. 1. `GET /v1/connect/slack?profileId=...` with no other parameters returns an `authUrl`. The user approves the workspace on Slack. 2. In the standard flow, Pozy then shows a channel picker. In [headless mode](https://pozy.ai/docs/connect/headless), your callback gets a `pendingDataToken`: list the channels with `GET /v1/connect/slack?profileId=...&pendingDataToken=...` (public and private, up to 2,000; `isMember` says whether the bot is already in the channel). 3. `POST /v1/connect/slack` with `channelId` or `channelIds` and the `pendingDataToken` saves each channel as an account. To add another channel from a workspace you already connected, list with `accountId` of an existing Slack account instead of `pendingDataToken`, then post with `accountId`. No second install needed. ## Errors | Status | When | | --- | --- | | 400 `invalid_field_value` | A field is malformed, for example a `siteUrl` that is not HTTPS or a Bluesky `state` without a profile id. | | 400 with the platform's reason | The token or app password was rejected. `error` says why. Ask the user to create a new one. | | 404 `profile_not_found` | The profile is not in this workspace. | | 409 | The account is already connected somewhere it cannot be shared, for example a WhatsApp number held by another workspace. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/connect/bluesky/credentials`](https://pozy.ai/docs/api/connectBlueskyCredentials) | Connect Bluesky account | | [`GET /v1/connect/wordpress`](https://pozy.ai/docs/api/getWordPressAuthUrl) | Get WordPress.com OAuth connect URL | | [`POST /v1/connect/wordpress/token`](https://pozy.ai/docs/api/connectWordPressWithApplicationPassword) | Connect self-hosted WordPress with an application password | | [`GET /v1/connect/webflow`](https://pozy.ai/docs/api/getWebflowConnectUrl) | Get Webflow OAuth URL | | [`POST /v1/connect/webflow/token`](https://pozy.ai/docs/api/connectWebflowWithSiteToken) | Connect Webflow with a site API token | | [`GET /v1/connect/shopify`](https://pozy.ai/docs/api/getShopifyConnectUrl) | Get Shopify OAuth connect URL | | [`POST /v1/connect/shopify/token`](https://pozy.ai/docs/api/connectShopifyWithToken) | Connect a Shopify store with a custom-app Admin token | | [`POST /v1/connect/whatsapp/credentials`](https://pozy.ai/docs/api/connectWhatsAppCredentials) | Connect WhatsApp via credentials | | [`POST /v1/connect/openai-ads/credentials`](https://pozy.ai/docs/api/connectOpenAIAdsCredentials) | Connect an OpenAI Ads account | | [`GET /v1/connect/slack`](https://pozy.ai/docs/api/listSlackChannels) | List Slack channels for the channel picker | | [`POST /v1/connect/slack`](https://pozy.ai/docs/api/connectSlackChannel) | Connect a Slack channel | --- # Welcome screen Show your name, logo and colours before the platform's consent screen, per link or for every connect in the workspace. The welcome screen is a short page Pozy shows before sending the user to the platform. It says who is asking ("Acme wants to connect to your Instagram"), with your logo, and forwards to the platform's consent screen after about a second. Users who can see whose app is asking finish the connect more often. The same branding carries through to the picker (Pages, locations, boards) and to the closing screen, so the whole flow looks like part of your product. ## Turn it on for one link Add `welcome=true` and your brand to the connect call: ```bash cURL curl "https://api.pozy.ai/v1/connect/instagram?profileId=6650c1f2a9e4b7d3c8a1f0e2&welcome=true&brandName=Acme&logoUrl=https://acme.com/logo.png&primaryColor=%234F46E5&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/instagram?profileId=6650c1f2a9e4b7d3c8a1f0e2&welcome=true&brandName=Acme&logoUrl=https:%2F%2Facme.com%2Flogo.png&primaryColor=%234F46E5&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // data.authUrl now opens the welcome screen on pozy.ai ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/instagram", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "welcome": "true", "brandName": "Acme", "logoUrl": "https://acme.com/logo.png", "primaryColor": "#4F46E5", "redirect_url": "https://app.example.com/connected", }, timeout=30, ) ``` The returned `authUrl` points at the welcome screen instead of the platform. Nothing else in your flow changes. ## Save it for the whole workspace Save your branding once and every connect URL created with your keys shows it, with no extra parameters. Do it in the dashboard under **Settings → White label**, or with the API: ```bash curl -X PUT "https://api.pozy.ai/v1/connect/welcome-screen" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "showByDefault": true, "brandName": "Acme", "logoUrl": "https://acme.com/logo.png", "theme": { "primaryColor": "#4F46E5", "heading": "Connect {platform} to {brand}", "trustLine": "{brand} only gets the access you approve. Disconnect any time.", "cornerRadius": 12, "font": "sans", "termsUrl": "https://acme.com/terms", "privacyUrl": "https://acme.com/privacy" } }' ``` `showByDefault` is required. `GET /v1/connect/welcome-screen` returns what is saved. Changing the saved screen needs a workspace owner or admin, or a full-scope API key. Profile-scoped keys cannot change it. ## How the options combine | You send | Result | | --- | --- | | Nothing, nothing saved | No welcome screen. `authUrl` goes straight to the platform. | | Nothing, saved with `showByDefault: true` | The saved screen. | | `welcome=true` | The screen, with your parameters over the saved branding (theme merged key by key). | | `welcome=false` | No screen for this link, even if one is saved. | | `handoff=true` | Always the screen: it is the first thing the phone shows. See [Phone handoff](https://pozy.ai/docs/connect/phone-handoff). | | `headless=true` | No screen, unless `handoff=true`. `welcome=true` with `headless=true` is rejected. | Branding parameters (`brandName`, `logoUrl`, `primaryColor`, `welcomeTheme`) only apply when the screen is shown. Sending them on a link without the screen returns `400 invalid_field_value` with `param: welcome`, so you notice instead of wondering why your logo is missing. ## Branding parameters | Parameter | Limit | | --- | --- | | `brandName` | 1 to 60 characters. | | `logoUrl` | An `http(s)` URL, up to 2,048 characters. | | `primaryColor` | `#RRGGBB`. URL-encode the `#` as `%23` in a query string. | | `welcomeTheme` | A JSON object with any of the theme keys below, up to 4,000 characters once encoded. | ## Theme keys The same keys work in `welcomeTheme` on a link and in `theme` on the saved screen. | Key | Type | Notes | | --- | --- | --- | | `heading` | text, up to 120 | Default: "{brand} wants to connect to your {platform}". | | `trustLine` | text, up to 200 | The reassurance line under the heading. | | `consentLine` | text, up to 200 | What happens next on the platform. | | `statusLine` | text, up to 200 | Shown while forwarding. Default: "Taking you to {platform}". | | `buttonLabel` | text, up to 30 | The fallback button if the forward stalls. Default: "Continue to {platform}". | | `successHeading`, `errorHeading` | text, up to 120 | Closing screen titles. | | `successBody`, `errorBody` | text, up to 200 | Closing screen text. | | `primaryColor`, `accentColor`, `pageColor`, `cardColor`, `textColor` | `#RRGGBB` | Button, icons, page background, card background and text. | | `cornerRadius` | integer, 0 to 24 | Card and button radius in pixels. | | `font` | `sans`, `serif` or `mono` | | | `bannerStyle` | `halftone`, `solid` or `plain` | The band at the top of the card. | | `trustLineIcon`, `consentLineIcon` | `shield`, `lock`, `link`, `check`, `user`, `sparkles` or `none` | | | `hide` | list | Any of `heading`, `trustLine`, `consentLine`, `statusLine`, `successBody`, `errorBody`. | | `termsUrl`, `privacyUrl` | `https://` URL | Linked in the footer. | `{brand}` and `{platform}` in any text are replaced with your brand name and the platform's name. Unknown keys are rejected, so a typo fails loudly. > **Keep it short:** > > The screen is on view for about a second before it forwards. Put the reassurance in `trustLine` and keep `heading` to one line. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/welcome-screen`](https://pozy.ai/docs/api/getConnectWelcomeScreen) | Get the saved welcome screen | | [`PUT /v1/connect/welcome-screen`](https://pozy.ai/docs/api/updateConnectWelcomeScreen) | Save the welcome screen | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | --- # Phone handoff Let users on a computer finish connecting on their phone by scanning a QR code, where the platform's app already has them signed in. Many people are not signed in to Instagram, TikTok or WhatsApp on their computer, but they are on their phone. A phone handoff turns the connect into a QR code: the user scans it, approves in the app they are already signed in to, and goes back to the computer, where your app sees the new account. ## Start a handoff Add `handoff=true` to the connect call. Your brand parameters work as usual, because the phone always shows the [welcome screen](https://pozy.ai/docs/connect/welcome-screen) first. ```bash cURL curl "https://api.pozy.ai/v1/connect/tiktok?profileId=6650c1f2a9e4b7d3c8a1f0e2&handoff=true&brandName=Acme" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/tiktok?profileId=6650c1f2a9e4b7d3c8a1f0e2&handoff=true&brandName=Acme", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // data.handoff: { id, url, expiresAt } ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/tiktok", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1f2a9e4b7d3c8a1f0e2", "handoff": "true", "brandName": "Acme"}, timeout=30, ) handoff = resp.json()["handoff"] ``` ```json { "authUrl": "https://pozy.ai/connect/welcome/3f9c2b1e-8d47-4a6b-9e21-5c0d7f3a8b64", "state": "...", "handoff": { "id": "3f9c2b1e-8d47-4a6b-9e21-5c0d7f3a8b64", "url": "https://pozy.ai/connect/m/3f9c2b1e-8d47-4a6b-9e21-5c0d7f3a8b64", "expiresAt": "2026-10-30T10:45:00.000Z" } } ``` ## Show the code You have two options: - **Draw your own QR code** of `handoff.url` with any QR library, inside your own UI. - **Open `handoff.url` on the computer.** On a desktop browser it shows a ready-made page with the QR code that follows the status live, plus a link to continue on the computer instead. `authUrl` is still valid. Offer it as a "Connect on this computer" link for users who are signed in on desktop. The code works for 30 minutes. ## Wait for the result Poll [`GET /v1/connect/handoffs/{handoffId}`](https://pozy.ai/docs/api/getConnectHandoff) every 2 to 5 seconds while the code is on screen, or listen for the `account.connected` webhook. ```bash curl "https://api.pozy.ai/v1/connect/handoffs/3f9c2b1e-8d47-4a6b-9e21-5c0d7f3a8b64" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "id": "3f9c2b1e-8d47-4a6b-9e21-5c0d7f3a8b64", "status": "connected", "platform": "tiktok", "accounts": [ { "id": "6651d3a7b2c9e4f1a0b8c7d6", "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "username": "acmecoffee" } ], "error": null } ``` | `status` | Meaning | What to show | | --- | --- | --- | | `waiting` | Nobody has scanned the code yet. | The QR code. | | `opened` | The phone opened the link. | "Finish on your phone." | | `connected` | Done. `accounts` lists what connected. | Success, and stop polling. | | `failed` | The flow ended with an error. `error.code` uses the same values as the [redirect errors](https://pozy.ai/docs/connect/hosted-connect#errors-on-the-redirect). | The message and a retry button. | | `expired` | 30 minutes passed. | A button that starts a new handoff. | Stop polling at `connected`, `failed` or `expired`. A profile-scoped key can only read handoffs for its own profiles; anything else returns `404 handoff_not_found`. ## What the phone shows 1. Your welcome screen, then the platform's app or site, where the user is usually already signed in. 2. Any picker the platform needs (Pages, locations, numbers). 3. A closing screen that tells them to go back to the computer. ## Rules - `handoff=true` cannot be combined with `welcome=false`, `headless=true` or `redirect_url`. The flow ends on the phone, so there is nowhere to redirect. These combinations return `400 invalid_field_value` with `param: handoff`. - Use the handoff for platforms people mostly use on mobile: Instagram, TikTok, Snapchat, Threads and WhatsApp. For Google, Microsoft or LinkedIn a normal redirect is usually smoother. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/handoffs/{handoffId}`](https://pozy.ai/docs/api/getConnectHandoff) | Get a phone handoff | --- # Scopes and permissions Ask only for the permission areas your product uses, see what each account was actually granted, and handle missing permissions. When a user connects an account, the platform asks them to approve a list of permissions. By default Pozy asks for everything it supports on that platform, so the account can post, read analytics, answer comments and handle messages. If your product only publishes, you can ask for less. A shorter consent screen is easier to approve, and some platforms review apps by what they request. Two different things are called permissions in Pozy: | | Controls | Set by | | --- | --- | --- | | Platform scopes | What Pozy may do on the connected account (post, read insights, reply). | The user, on the platform's consent screen, from the areas you request. | | API key permissions | What your key may do in Pozy (read only, which resource groups). | You, when you create the key. See [Authentication](https://pozy.ai/docs/getting-started/authentication). | This page is about the first. ## Request fewer areas Pass `scopes` with a comma-separated list of areas on the connect call: ```bash cURL curl "https://api.pozy.ai/v1/connect/linkedin?profileId=6650c1f2a9e4b7d3c8a1f0e2&scopes=posting,analytics&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/linkedin?profileId=6650c1f2a9e4b7d3c8a1f0e2&scopes=posting,analytics&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/linkedin", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "scopes": "posting,analytics", "redirect_url": "https://app.example.com/connected", }, timeout=30, ) ``` The consent screen then lists only those areas, plus what every connection needs: the user's identity, token refresh, and the list of Pages, organizations or channels to pick from. ## Areas | Area | What it allows | | --- | --- | | `posting` | Publishing, editing and deleting posts. | | `analytics` | Post and account insights, follower counts. | | `comments` | Reading and replying to comments, and [first comments](https://pozy.ai/docs/publishing/first-comments) on your own posts. | | `messaging` | Reading and sending DMs in the [inbox](https://pozy.ai/docs/inbox). | | `ads` | Ad accounts, campaigns and audiences. | The exact platform scopes behind each area are listed per platform in the `platforms` field of [`GET /v1/connect/oauth-apps`](https://pozy.ai/docs/api/listOAuthApps). ### Which platforms accept `scopes` | Accepts `scopes` | Rejects it (the consent screen cannot be reduced) | | --- | --- | | Facebook, Instagram (both login methods), LinkedIn, X, TikTok, YouTube, Threads, Pinterest, Google Business Profile, Reddit, Slack | Bluesky, Telegram, Discord, Snapchat, WhatsApp | Sending `scopes` to a platform that rejects it, or an unknown area, returns `400 invalid_field_value` with `param: scopes`. ### Google and Microsoft Google and Microsoft accounts require `scopes`, with their own areas: | Area | Google | Microsoft | | --- | --- | --- | | `email` | Read, search, send, label and trash Gmail | Read, send, move and trash Outlook mail | | `calendar` | Read and write events, free/busy | Read and write events, free/busy | | `files` | Files Pozy created or the user picked in Drive | The user's OneDrive files | | `contacts` | Read-only sync of Google Contacts | Read-only sync of Outlook contacts | ```bash curl "https://api.pozy.ai/v1/connect/google?profileId=6650c1f2a9e4b7d3c8a1f0e2&scopes=email,calendar&loginHint=ana@acme.com" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Areas only accumulate. Connecting the same mailbox again with more areas widens the same account and keeps its `accountId`. Leaving an area out of a reconnect does not remove it: to drop one, disconnect the account and connect it again. See [Email, calendar & files](https://pozy.ai/docs/productivity). ## Ask for less, carefully > **A posting-only account is posting-only:** > > An account connected with `scopes=posting` cannot read analytics or the inbox, and first comments are skipped because they need `comments`. Adding an area later means the user connects again. Ask for the areas you will use in the next few months, not just today. A second consent screen is a point where users drop off. ## See what was granted Users can untick permissions on some consent screens. What you asked for and what you got can differ. [Account health](https://pozy.ai/docs/connect/account-health) shows the result: ```json { "permissions": { "posting": [{ "scope": "w_member_social", "granted": true, "required": true }], "analytics": [{ "scope": "r_organization_social", "granted": false, "required": true }], "canPost": true, "canFetchAnalytics": false, "missingRequired": ["r_organization_social"] } } ``` `canPost` and `canFetchAnalytics` are the two flags to check before you offer a feature for an account. `GET /v1/accounts/health` returns them for every account at once. ## When a permission is missing | Signal | Platform | Fix | | --- | --- | --- | | `canPost` or `canFetchAnalytics` is `false` in health | Any | Reconnect with `reconnectAccountId` and the areas you need. | | `412 account_scope_missing` on a call | Google, Microsoft | Reconnect with that area in `scopes`. | | `412 admin_consent_required` | Microsoft work and school accounts | The organization only lets admins approve apps. `details.adminConsentUrl` is a link for their administrator; [`GET /v1/connect/microsoft/admin-consent`](https://pozy.ai/docs/api/startMicrosoftAdminConsent) creates a fresh one. | | `missing_tiktok_permissions` on the redirect | TikTok | The user left out a permission the account needs, and nothing changed. Connect again and accept every permission. | | `account.permissions_changed` webhook | Accounts on [your own platform app](https://pozy.ai/docs/connect/own-apps) | Your app lost one or more areas (`missingScopeAreas`), for example after a platform review. `reason` explains why. | ## Webhook events | Event | When it fires | | --- | --- | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`account.permissions_changed`](https://pozy.ai/docs/webhooks/events#account-permissions-changed) | Account permissions changed. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | | [`GET /v1/accounts/health`](https://pozy.ai/docs/api/getAllAccountsHealth) | Check accounts health | | [`GET /v1/connect/oauth-apps`](https://pozy.ai/docs/api/listOAuthApps) | List your OAuth apps | | [`GET /v1/connect/microsoft/admin-consent`](https://pozy.ai/docs/api/startMicrosoftAdminConsent) | Admin-consent link for a Microsoft tenant | --- # Use your own platform apps Coming soon. Connect your customers through your own app on each platform, so the consent screen shows your name and the platform's quotas are yours. > **Not available yet:** > > Using your own platform apps is coming soon. Until it is, `POST /v1/connect/oauth-apps` answers `403` with `code: "feature_not_available"`, and every account connects through the platform's own consent screen with the default app. This page describes how it will work, so you can prepare your apps and their reviews in advance. By default, accounts connect through the platform's own consent screen. If you run a product on top of Pozy you may want that screen to show your own app name and logo, and to get your own rate limits on platforms that set them per app. You will be able to register your own app for a platform and connect accounts through it, while Pozy still handles tokens, publishing, analytics and the inbox. Nothing changes for accounts you connect without an app id: they stay on the default app. Switch one platform at a time, at your own pace. ## Before you start - A workspace owner or admin, or a full-scope API key. Profile-scoped keys cannot manage apps and get `403 insufficient_permissions`. - An app on the platform, created in its developer console, with its client ID and secret. - Time for the platform's review. Most platforms only let outside accounts connect after they approve the app. ## Supported platforms | Platform | Paste the redirect URI here | What the platform must approve first | | --- | --- | --- | | Instagram | Meta app → Instagram → API setup with Instagram login | Advanced access, through Meta's App Review | | Facebook | Meta app → Facebook Login for Business → Valid OAuth redirect URIs | Advanced access, through Meta's App Review | | Threads | Meta app → Threads use case → Redirect callback URLs | Meta's App Review | | YouTube | Google Cloud → OAuth client of type Web application → Authorized redirect URIs | A verified consent screen | | Google Business Profile | Google Cloud → OAuth client of type Web application → Authorized redirect URIs | Access to the Business Profile APIs, granted by Google | | Google (Gmail, Calendar, Drive) | Google Cloud → OAuth client of type Web application → Authorized redirect URIs | Verification of Gmail's restricted scopes | | Microsoft (Outlook, Calendar, OneDrive) | Azure → App registrations → Authentication → Web platform redirect URI | Nothing | | X | Developer portal → User authentication settings → Callback URI | Nothing | | LinkedIn | Developer app → Auth → Authorized redirect URLs | Community Management API access, needed for comments and analytics | | TikTok | Developer app → Login Kit → Redirect URI | Content Posting API, with direct posting enabled | | Pinterest | Developer app → Settings → Redirect URIs | Standard access | | Snapchat | Business Manager → OAuth apps → Redirect URI | Public Profile API access | | Slack | Slack app → OAuth & Permissions → Redirect URLs | Nothing, for installs in your own Slack workspace | | WhatsApp | No redirect: numbers connect with credentials | A system user token that can send messages and manage WhatsApp | `GET /v1/connect/oauth-apps` returns the same list in `platforms`, each with its `redirectUri`, the scopes every connection needs (`coreScopes`) and the scopes behind each area (`scopeAreas`). ## Set up an app ### Add the app Send the client ID and secret. Pozy checks them with the platform before saving. ```bash cURL curl -X POST "https://api.pozy.ai/v1/connect/oauth-apps" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "youtube", "label": "Acme Studio", "clientId": "4471209931-q8r2s6t0.apps.googleusercontent.com", "clientSecret": "GOCSPX-..." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/oauth-apps", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "youtube", label: "Acme Studio", clientId: "4471209931-q8r2s6t0.apps.googleusercontent.com", clientSecret: process.env.YOUTUBE_CLIENT_SECRET!, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const appId = data.app.id; const redirectUri = data.app.redirectUri; ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/connect/oauth-apps", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "platform": "youtube", "label": "Acme Studio", "clientId": "4471209931-q8r2s6t0.apps.googleusercontent.com", "clientSecret": os.environ["YOUTUBE_CLIENT_SECRET"], }, timeout=30, ) app = resp.json()["app"] ``` You get `201` back with the new app: its `id`, its `redirectUri` and a `status`. `ok` means the platform took the keys. `unchecked` means the platform has no way to check them yet; Slack, for one, only validates an app when it is first installed. If the platform approved your app for only part of what Pozy can do, list the parts in `scopeAreas`: any of `posting`, `analytics`, `comments` and `messaging`. A LinkedIn app starts with `posting` alone, since the rest depends on LinkedIn's Community Management API. ### Register the redirect URI Take `redirectUri` from the response and add it to your app on the platform, at the spot the table above shows. Character for character, no slash at the end. The platform blocks every connect until the two match. ### Connect accounts through the app Add `oauthAppId` to the usual [connect call](https://pozy.ai/docs/connect/hosted-connect): ```bash curl "https://api.pozy.ai/v1/connect/youtube?profileId=6650c1f2a9e4b7d3c8a1f0e2&oauthAppId=$APP_ID&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Your app's name is what the user approves. After that, everything for the account (refreshing its token, posting, analytics, inbox) uses your keys. `GET /v1/accounts` shows the app behind each account in its `oauthAppId` field. ### Set up events where needed Most platforms need nothing else. These do: | Platform | What to do | | --- | --- | | Facebook, WhatsApp | Nothing manual: adding the app also subscribes Pozy to its events. The result is in `app.webhook`. | | Instagram | Include `metaAppId` and `metaAppSecret` of the parent Meta app. Pozy needs them to subscribe to the Instagram app's events. | | TikTok | In your TikTok app, set the webhook callback to the `sharedWebhookUrl` Pozy returns. Posting does not depend on it. | | Slack | Add `slackSigningSecret` with the app. Then, under Event Subscriptions in Slack, use `slackEvents.requestUrl` as the request URL and add each bot event from `slackEvents.botEvents`. | ## Manage apps | Job | Call | | --- | --- | | List your apps | `GET /v1/connect/oauth-apps` (paginated with `cursor`) | | Read one | `GET /v1/connect/oauth-apps/{appId}` | | Rotate the secret, rename, change areas | `PATCH /v1/connect/oauth-apps/{appId}` | | Check the keys again | `POST /v1/connect/oauth-apps/{appId}/test` | | Remove an app | `DELETE /v1/connect/oauth-apps/{appId}` | - The client secret is never returned after you add it. Rotating it with `PATCH` applies to every account on the app. - `test` refreshes `status`, `lastCheckedAt` and `lastError`. `invalid` means connects and token refreshes through the app fail until you fix the keys. - You can add several apps for the same platform. Nothing picks one automatically: a connect without `oauthAppId` uses the default app. - Removing an app that still has accounts returns `409 OAUTH_APP_IN_USE` with `details.accountCount`. Pass `confirm=true` to remove it anyway: those accounts are marked as needing reconnection and each one fires `account.disconnected`. The response says how many in `disconnectedAccounts`. ## Limits - Instagram apps cover Instagram login only. Ads connects and WhatsApp Embedded Signup always use the default app. - A WhatsApp app you add has its webhook callback set to Pozy, replacing any other. Keep a separate Meta app for Pozy. - Fresh Google Cloud projects get a tiny YouTube upload quota, roughly six videos a day. Request a higher quota from Google before customers depend on it. - If your app loses an area, for example after a platform review, the accounts on it fire `account.permissions_changed` with `missingScopeAreas`. See [Scopes](https://pozy.ai/docs/connect/scopes). ## Errors | Error | Why | What to do | | --- | --- | --- | | `OAUTH_APP_KEYS_REJECTED` | The platform said no to the client ID or secret, so nothing was stored. | Copy both values from the console again. | | `409 OAUTH_APP_ALREADY_EXISTS` | That client ID is already registered for the platform. | Reuse the app named in `details.appId`, or `PATCH` it with a new secret. | | `403 insufficient_permissions` or `role_forbidden` | The key is profile-scoped, or the user is not an owner or admin. | Use a full-scope key or an admin account. | | The platform shows a redirect URI error | The URI in your app does not match `redirectUri`. | Paste `redirectUri` exactly. | | The consent screen warns that the app is unapproved | The platform's review of your app is not done. | Complete the review, or list the user as a tester of the app. | | `webhook.status` is `failed` | Subscribing to your Meta app's events did not work. | `webhook.error` says why. For Instagram, `PATCH` the app with `metaAppId` and `metaAppSecret`. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/oauth-apps`](https://pozy.ai/docs/api/listOAuthApps) | List your OAuth apps | | [`POST /v1/connect/oauth-apps`](https://pozy.ai/docs/api/createOAuthApp) | Add an OAuth app (not available yet) | | [`GET /v1/connect/oauth-apps/{appId}`](https://pozy.ai/docs/api/getOAuthApp) | Get an OAuth app | | [`PATCH /v1/connect/oauth-apps/{appId}`](https://pozy.ai/docs/api/updateOAuthApp) | Update an OAuth app | | [`DELETE /v1/connect/oauth-apps/{appId}`](https://pozy.ai/docs/api/deleteOAuthApp) | Remove an OAuth app | | [`POST /v1/connect/oauth-apps/{appId}/test`](https://pozy.ai/docs/api/testOAuthApp) | Check an OAuth app's keys | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | --- # Account health Check token status, granted permissions and sync status for every connected account, and catch disconnects before your users do. Connected accounts break for reasons outside your control: a password change on the platform, a revoked app, an expired token, a Page role removed. Account health tells you, per account, whether Pozy can still post and read analytics, what is wrong if not, and what to do about it. ## Check every account `GET /v1/accounts/health` returns a summary and one row per account. Filter with `profileId`, `platform` or `status`. ```bash cURL curl "https://api.pozy.ai/v1/accounts/health?status=error" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/health?status=error", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const a of data.accounts) { if (a.needsReconnect) console.log(`${a.platform} @${a.username} needs reconnecting: ${a.issues.join("; ")}`); } ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/accounts/health", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"status": "error"}, timeout=30, ) for a in resp.json()["accounts"]: if a["needsReconnect"]: print(a["platform"], a["username"], a["issues"]) ``` ```json { "summary": { "total": 14, "healthy": 11, "warning": 2, "error": 1, "needsReconnect": 1 }, "accounts": [ { "accountId": "6651d3a7b2c9e4f1a0b8c7d6", "platform": "linkedin", "username": "acme", "profileId": "6650c1f2a9e4b7d3c8a1f0e2", "status": "error", "canPost": false, "canFetchAnalytics": false, "tokenValid": false, "needsReconnect": true, "issues": ["The access token is no longer valid."], "disconnectionType": "unintentional", "unhealthySince": "2026-10-29T18:04:11.000Z", "analyticsSync": { "status": "failing", "lastSyncedAt": "2026-10-28T06:00:00.000Z" } } ] } ``` A profile-scoped key only sees the accounts in its profiles, and the summary counts only those. ## Check one account in detail `GET /v1/accounts/{accountId}/health` adds the token details, the scope-by-scope permission list and recommendations. | Field | What it tells you | | --- | --- | | `status` | `healthy`, `warning` or `error`. | | `tokenStatus` | `valid`, `expiresAt`, `expiresIn` and `needsRefresh`. Pozy refreshes tokens on its own; this is for diagnosis. | | `permissions` | Each scope in the `posting`, `analytics` and `optional` groups with `granted` and `required`, plus `canPost`, `canFetchAnalytics` and `missingRequired`. See [Scopes](https://pozy.ai/docs/connect/scopes). | | `issues` | Plain-language problems, ready to show an operator. | | `recommendations` | What to do next, for example reconnecting through a specific endpoint. | | `analyticsSync` | `ok`, `failing`, `stalled` or `never_synced`, with `lastSyncedAt`. A failing or stalled sync raises a healthy account to at least `warning`. | | `messagingRestriction` | Set when the platform has paused messaging on the account, with the platform's `code`, `message` and `pausedUntil`. | | `platformConnection` | WhatsApp only: a live check of the number behind the account. See below. | | `disconnectReason`, `disconnectionType`, `unhealthySince` | Why and since when the account stopped working. | ### What `status` means | `status` | Meaning | Typical action | | --- | --- | --- | | `healthy` | Posting and analytics work. | None. | | `warning` | The account works, but something needs attention, for example a failing analytics sync. | Read `issues`. | | `error` | Pozy cannot use the account for at least one core job. | Reconnect, or fix the cause in `issues`. | ### Meta accounts For Facebook, Instagram and Threads, Pozy also checks the token live with Meta. Meta sometimes refuses a valid token for a moment, so a refusal does not mark the token invalid if an analytics sync succeeded with it in the last six hours; `issues` then says the check was inconclusive. Health checks never disconnect an account on their own. ### WhatsApp A WhatsApp account's token can be valid while Meta no longer serves the number behind it, for example after the number was disconnected from the WhatsApp Business app on the phone. `platformConnection` is the live check: - `platformConnection.status: "disconnected"` sets the overall `status` to `error`. - `inboundWebhookSubscribed: false` means incoming messages are not reaching Pozy. It adds an issue and sets `status` to at least `warning`. For WhatsApp, poll health rather than relying only on the `account.disconnected` event. See [WhatsApp phone numbers](https://pozy.ai/docs/whatsapp/phone-numbers). ## How disconnects reach you `account.disconnected` fires when an account stops working or is removed. How fast depends on what happened: | Cause | `disconnectionType` | When the event fires | | --- | --- | --- | | You called `DELETE /v1/accounts/{accountId}` | `intentional` | Immediately. | | WhatsApp number disconnected from the phone | `unintentional` | Within seconds of Meta's notice. `reason` carries Meta's explanation. | | A publish or analytics sync gets a definitive auth error | `unintentional` | On that call. For publishing, Pozy tries a token refresh first, so the event means the refresh failed too. | | Access revoked quietly on the platform side | `unintentional` | When Pozy's periodic checks confirm it, from minutes to about a day later. | Do not treat the absence of `account.disconnected` as proof an account works. Combine the webhook with a scheduled health check. ## Reconnect an account Send the user through the connect flow again with `reconnectAccountId`. The account keeps its id, posts and analytics history. ```bash curl "https://api.pozy.ai/v1/connect/linkedin?profileId=6650c1f2a9e4b7d3c8a1f0e2&reconnectAccountId=6651d3a7b2c9e4f1a0b8c7d6&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` For standalone ads connections (Google Ads, Reddit Ads), `recommendations` points to `GET /v1/connect/{platform}/ads` instead. Its `authUrl` reconnects the same ads account. > **A reconnect banner that works:** > > Show a banner in your app for every account with `needsReconnect: true`. Name the platform and the handle, and add a button that calls your backend to create the reconnect URL. ## Facebook Page events Lead ads, comments and messages on a Facebook Page arrive through the Page's webhook subscription. If one of them stops while the rest still work, read the subscription live with [`GET /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/getPageWebhookSubscription) and repair it with [`POST /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/resyncPageWebhookSubscription). ## Disconnect an account `DELETE /v1/accounts/{accountId}` disconnects the account and fires `account.disconnected` with `disconnectionType: "intentional"`. Calling it again for the same account returns `404`. ## A daily health job ```ts export async function dailyHealthCheck() { const res = await fetch("https://api.pozy.ai/v1/accounts/health", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const broken = data.accounts.filter((a) => a.status === "error" || a.needsReconnect); for (const a of broken) { await notifyOwner(a.profileId, `Your ${a.platform} account @${a.username} needs to be reconnected.`); } } ``` `notifyOwner` is your own code: email, in-app message, or a Slack alert to your support team. ## Webhook events | Event | When it fires | | --- | --- | | [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`account.permissions_changed`](https://pozy.ai/docs/webhooks/events#account-permissions-changed) | Account permissions changed. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/health`](https://pozy.ai/docs/api/getAllAccountsHealth) | Check accounts health | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | | [`GET /v1/accounts`](https://pozy.ai/docs/api/listAccounts) | List accounts | | [`DELETE /v1/accounts/{accountId}`](https://pozy.ai/docs/api/deleteAccount) | Disconnect account | | [`GET /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/getPageWebhookSubscription) | Read a Facebook Page's webhook subscription | | [`POST /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/resyncPageWebhookSubscription) | Re-subscribe a Facebook Page to Pozy's webhooks | --- # Account groups Save named sets of accounts, such as all of a brand's channels, and reuse them when you post or report. An account group is a saved, named list of account ids, for example "Acme launch channels" or "Spain stores". Groups do not change how accounts behave. They save you from rebuilding the same list of accounts every time someone posts, filters a report or sets up an automation. Groups can mix accounts from different profiles and platforms. Removing a group never touches the accounts in it. ## Create a group ```bash cURL curl -X POST "https://api.pozy.ai/v1/account-groups" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme launch channels", "accountIds": ["6651d3a7b2c9e4f1a0b8c7d6", "6651d3a7b2c9e4f1a0b8c7d7", "6651d3a7b2c9e4f1a0b8c7d8"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/account-groups", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Acme launch channels", accountIds: ["6651d3a7b2c9e4f1a0b8c7d6", "6651d3a7b2c9e4f1a0b8c7d7", "6651d3a7b2c9e4f1a0b8c7d8"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.group._id); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/account-groups", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "Acme launch channels", "accountIds": ["6651d3a7b2c9e4f1a0b8c7d6", "6651d3a7b2c9e4f1a0b8c7d7", "6651d3a7b2c9e4f1a0b8c7d8"], }, timeout=30, ) group_id = resp.json()["group"]["_id"] ``` ```json { "message": "Account group created", "group": { "_id": "6652a0c4d1e8f3b7a9c2e5d1", "name": "Acme launch channels", "accountIds": ["6651d3a7b2c9e4f1a0b8c7d6", "6651d3a7b2c9e4f1a0b8c7d7", "6651d3a7b2c9e4f1a0b8c7d8"] } } ``` Group names must be unique. Every account id must belong to this workspace, and your key must be able to reach its profile. ## List, rename and change | Job | Call | | --- | --- | | List groups | `GET /v1/account-groups` | | Rename, or replace the account list | `PUT /v1/account-groups/{groupId}` with `name`, `accountIds` or both | | Delete a group | `DELETE /v1/account-groups/{groupId}` | `PUT` replaces `accountIds` as a whole. To add one account, send the current list plus the new id. ## Post to a group A post targets accounts, not groups, so expand the group into `platforms` when you create the post. Look up each account's platform once with `GET /v1/accounts`: ```ts const groupsRes = await fetch("https://api.pozy.ai/v1/account-groups", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const groups = await groupsRes.json(); if (!groupsRes.ok) throw new Error(groups.error); const group = groups.groups.find((g) => g.name === "Acme launch channels")!; const accountsRes = await fetch("https://api.pozy.ai/v1/accounts", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const accounts = await accountsRes.json(); if (!accountsRes.ok) throw new Error(accounts.error); const byId = new Map(accounts.accounts.map((a) => [a._id, a])); const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "We open in Madrid on Saturday. First coffee is on us.", platforms: group.accountIds .filter((id) => byId.has(id)) .map((id) => ({ platform: byId.get(id)!.platform, accountId: id })), publishNow: true, }), }); if (!res.ok) throw new Error((await res.json()).error); ``` Filter out ids that no longer exist. A group keeps the ids you saved, even after one of those accounts is disconnected. ## Profile-scoped keys A key scoped to some profiles only sees groups whose accounts all live in those profiles. It cannot create or change a group that includes an account outside its scope: that returns `403 profile_out_of_scope`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/account-groups`](https://pozy.ai/docs/api/listAccountGroups) | List groups | | [`POST /v1/account-groups`](https://pozy.ai/docs/api/createAccountGroup) | Create group | | [`PUT /v1/account-groups/{groupId}`](https://pozy.ai/docs/api/updateAccountGroup) | Update group | | [`DELETE /v1/account-groups/{groupId}`](https://pozy.ai/docs/api/deleteAccountGroup) | Delete group | --- # Create a post Publish, schedule, queue or save a post to one or many connected accounts with a single POST /v1/posts request. Every post in Pozy starts with one request to `POST /v1/posts`. The same body publishes right away, schedules for later, drops into a queue or saves a draft, and it can target any mix of connected accounts. This page walks through that body field by field. You need an API key and at least one connected account. `GET /v1/accounts` lists the account ids you can post to ([Connecting accounts](https://pozy.ai/docs/connect)). ## Publish your first post This request publishes a text post to one LinkedIn account straight away: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We just shipped scheduled exports. Details on the blog.", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" } ], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "We just shipped scheduled exports. Details on the blog.", platforms: [{ platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21" }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status, data.post.platforms[0].platformPostUrl); ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "We just shipped scheduled exports. Details on the blog.", "platforms": [{"platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21"}], "publishNow": True, }, timeout=60, ) post = resp.json()["post"] print(post["status"], post["platforms"][0].get("platformPostUrl")) ``` With `publishNow`, the request waits for the platform to answer, so the response already holds the result and the public URL: ```json { "post": { "_id": "6721a8c04e9b2f1d7c3a5e88", "content": "We just shipped scheduled exports. Details on the blog.", "status": "published", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21", "status": "published", "platformPostId": "urn:li:share:7254471109021356032", "platformPostUrl": "https://www.linkedin.com/feed/update/urn:li:share:7254471109021356032", "publishedAt": "2026-10-30T10:02:11.000Z" } ], "createdAt": "2026-10-30T10:02:09.000Z" } } ``` ## The request body These fields sit at the top level of the body. Everything except `platforms` is optional, and `platforms` itself is optional for drafts. | Field | Type | What it does | | --- | --- | --- | | `content` | string | The text of the post. Optional when you attach media or every platform entry has its own `customContent`. | | `mediaItems` | array | Images, videos, GIFs or documents shared by every platform entry. See [Media](https://pozy.ai/docs/publishing/media). | | `platforms` | array | One entry per account to publish to. See the next section. | | `publishNow` | boolean | Publish inside this request. | | `scheduledFor` | date-time | When to publish. Read with `timezone`. See [Scheduling and timezones](https://pozy.ai/docs/publishing/scheduling). | | `timezone` | string | IANA zone name such as `Europe/Madrid`. Defaults to `UTC`. | | `queuedFromProfile` | string | A profile id. The post takes the next free slot in that profile's queue. See [Queues](https://pozy.ai/docs/publishing/queues). | | `queueId` | string | Pick a specific queue of that profile instead of its default one. | | `isDraft` | boolean | Save without publishing. Wins over every scheduling field. | | `requiresApproval` | boolean | Hold the post in `pending_approval` until someone approves it. See [Drafts and approvals](https://pozy.ai/docs/publishing/drafts-approvals). | | `dryRun` | boolean | Check TikTok publishing eligibility without saving anything. See [Validation](https://pozy.ai/docs/publishing/validation). | | `title` | string | A title stored with the post. Platform titles (YouTube, Pinterest, Reddit) go in that platform's `platformSpecificData`. | | `tags` | array of strings | Your own labels, for filtering with `GET /v1/posts?tags=`. They are never published. | | `hashtags`, `mentions` | array of strings | Stored on the post for reporting. To publish a hashtag or mention, write it in the text. | | `metadata` | object | Free-form data you want back later, such as your own record id. Echoed in every post webhook. | | `recycling` | object | Republish the post on a repeating schedule. See [Queues](https://pozy.ai/docs/publishing/queues#recycle-evergreen-posts). | | `tiktokSettings`, `facebookSettings` | object | Settings applied to every TikTok or Facebook entry. See [Platform settings](https://pozy.ai/docs/publishing/platform-settings). | ## The platforms array Each entry in `platforms` sends the post to one connected account. Two entries can point at the same platform when you want to publish to two accounts there. | Field | Required | What it does | | --- | --- | --- | | `platform` | Yes | The platform id: `instagram`, `tiktok`, `youtube`, `facebook`, `linkedin`, `twitter` (X), `threads`, `pinterest`, `bluesky`, `googlebusiness`, `snapchat`, `telegram`, `discord`, `slack` and others. | | `accountId` | Yes | The connected account to publish from. | | `customContent` | No | Replaces `content` for this entry only. | | `customMedia` | No | Replaces `mediaItems` for this entry only. | | `scheduledFor` | No | Replaces the top-level `scheduledFor` for this entry only. | | `platformSpecificData` | No | Options that only this platform understands, such as an Instagram first comment or a YouTube title. The shape depends on `platform`. | > **Warning:** > > Any account in your workspace is accepted, whichever profile it belongs to. If you publish on behalf of your own customers, only pass account ids you have stored against that customer, or use a [profile-scoped API key](https://pozy.ai/docs/build/tenant-keys) so a mistake is refused. ## Publish now, schedule, queue or draft The fields you send decide what happens to the post and which status it starts in: | You send | What happens | First status | | --- | --- | --- | | `publishNow: true` | Publishes during the request. The response carries the result. | `published`, `partial` or `failed` | | `scheduledFor` in the future | Publishes at that time. | `scheduled` | | `scheduledFor` in the past | Publishes during the request, like `publishNow`. | `published`, `partial` or `failed` | | `queuedFromProfile` | Takes the next free slot of the profile's queue. | `scheduled` | | `requiresApproval: true` and a time | Waits for approval, then follows the time. | `pending_approval` | | `isDraft: true`, or no scheduling field at all | Saved, never published. | `draft` | When more than one applies, `isDraft` wins over everything, and `publishNow` wins over `scheduledFor`. > **Tip:** > > A post created with `publishNow` can fail on one platform and succeed on another. The request still returns a 2xx response (207 when at least one platform failed), so always branch on `post.status` and on each `platforms[].status` instead of on whether the call threw. ## Post to several platforms at once One request can carry different text, media and options per platform. Here LinkedIn gets the long version, X gets a short one, and Instagram gets a square image and a first comment: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: launch-exports-2026-11-03" \ -d '{ "content": "Scheduled exports are live. Pick a time, pick a format, and the file lands in your inbox every week.", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_k3f9_exports-wide.jpg" }], "scheduledFor": "2026-11-03T09:30:00", "timezone": "Europe/Madrid", "metadata": { "campaignId": "cmp_2041" }, "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }, { "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23", "customContent": "Scheduled exports are live. Weekly files, zero clicks." }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "customMedia": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_m2p7_exports-square.jpg" }], "platformSpecificData": { "firstComment": "Setup guide: link in bio" } } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "launch-exports-2026-11-03", "Content-Type": "application/json", }, body: JSON.stringify({ content: "Scheduled exports are live. Pick a time, pick a format, and the file lands in your inbox every week.", mediaItems: [{ type: "image", url: "https://pozy.ai/media/temp/1730289600_k3f9_exports-wide.jpg" }], scheduledFor: "2026-11-03T09:30:00", timezone: "Europe/Madrid", metadata: { campaignId: "cmp_2041" }, platforms: [ { platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21" }, { platform: "twitter", accountId: "6710c3e2a9f14b0d8e5c7a23", customContent: "Scheduled exports are live. Weekly files, zero clicks.", }, { platform: "instagram", accountId: "6710c3e2a9f14b0d8e5c7a22", customMedia: [{ type: "image", url: "https://pozy.ai/media/temp/1730289600_m2p7_exports-square.jpg" }], platformSpecificData: { firstComment: "Setup guide: link in bio" }, }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post._id, data.post.status); // "scheduled" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "launch-exports-2026-11-03", }, json={ "content": "Scheduled exports are live. Pick a time, pick a format, and the file lands in your inbox every week.", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/temp/1730289600_k3f9_exports-wide.jpg"}], "scheduledFor": "2026-11-03T09:30:00", "timezone": "Europe/Madrid", "metadata": {"campaignId": "cmp_2041"}, "platforms": [ {"platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21"}, { "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23", "customContent": "Scheduled exports are live. Weekly files, zero clicks.", }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "customMedia": [{"type": "image", "url": "https://pozy.ai/media/temp/1730289600_m2p7_exports-square.jpg"}], "platformSpecificData": {"firstComment": "Setup guide: link in bio"}, }, ], }, timeout=30, ) print(resp.json()["post"]["status"]) # "scheduled" ``` Per-entry fields replace the top-level ones for that entry only. The LinkedIn entry above uses the shared text and image, the X entry swaps the text and keeps the image, and the Instagram entry keeps the text and swaps the image. ## Read the response The response holds the saved post. The fields you will use most: | Field | Meaning | | --- | --- | | `post._id` | The post id. Use it for every later call: get, update, retry, unpublish. | | `post.status` | The overall status. See [Post lifecycle](https://pozy.ai/docs/publishing/lifecycle). | | `post.scheduledFor` | The publish time as a UTC instant, ending in `Z`. | | `post.platforms[].status` | Status of each entry: `pending`, `processing`, `uploading`, `published`, `failed` or `cancelled`. | | `post.platforms[].platformPostUrl` | Public URL once that entry is published. | | `post.platforms[].errorMessage`, `errorCategory` | Why an entry failed. | | `warnings` | Things Pozy changed or ignored, such as a field placed on the wrong platform entry. The post was still saved. | Read `warnings` in development. A field put in the wrong place is dropped rather than rejected, and the warning is the only sign. ## Avoid duplicate posts Networks and job runners retry. To make a retry safe, send an `Idempotency-Key` header (up to 255 characters) that is unique per logical post. A second request with the same key within 24 hours returns the original post instead of creating a new one, even if the body changed. Separately, Pozy refuses the same text and media sent to the same account twice within 24 hours with a `409`. The error names the existing post so you can link to it: ```json { "error": "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours.", "details": { "accountId": "6710c3e2a9f14b0d8e5c7a21", "platform": "linkedin", "existingPostId": "6721a8c04e9b2f1d7c3a5e88" } } ``` To repost on purpose, change the text, the media or the account. More on retries in [Idempotency](https://pozy.ai/docs/getting-started/idempotency). ## Find out what happened A scheduled or queued post publishes later, so the create response cannot tell you the outcome. Subscribe to post webhooks instead of polling: - `post.platform.published` and `post.platform.failed` fire per account as soon as each one finishes. - `post.published`, `post.partial` or `post.failed` fires once, after every account has finished. Put your own id in `metadata` when you create the post and it comes back in each of those events, so your handler never has to look the post up. See [Set up webhooks](https://pozy.ai/docs/webhooks). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`GET /v1/posts/{postId}`](https://pozy.ai/docs/api/getPost) | Get post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`DELETE /v1/posts/{postId}`](https://pozy.ai/docs/api/deletePost) | Delete post | | [`POST /v1/posts/bulk-upload`](https://pozy.ai/docs/api/bulkUploadPosts) | Bulk upload from CSV | | [`POST /v1/posts/{postId}/retry`](https://pozy.ai/docs/api/retryPost) | Retry failed post | | [`POST /v1/posts/{postId}/review`](https://pozy.ai/docs/api/reviewPost) | Approve or reject a post | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | | [`POST /v1/posts/{postId}/pin`](https://pozy.ai/docs/api/pinPost) | Pin post | | [`POST /v1/posts/{postId}/unpin`](https://pozy.ai/docs/api/unpinPost) | Unpin post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`POST /v1/posts/{postId}/update-metadata`](https://pozy.ai/docs/api/updatePostMetadata) | Update post metadata | ## Webhook events | Event | When it fires | | --- | --- | | [`post.scheduled`](https://pozy.ai/docs/webhooks/events#post-scheduled) | Post scheduled. | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.partial`](https://pozy.ai/docs/webhooks/events#post-partial) | Post partial. | | [`post.cancelled`](https://pozy.ai/docs/webhooks/events#post-cancelled) | Post cancelled. | | [`post.recycled`](https://pozy.ai/docs/webhooks/events#post-recycled) | Post recycled. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`post.tiktok.url_resolved`](https://pozy.ai/docs/webhooks/events#post-tiktok-url-resolved) | TikTok post URL resolved. | | [`post.tracking_completed`](https://pozy.ai/docs/webhooks/events#post-tracking-completed) | Post tracking completed. | | [`post.authenticity_changed`](https://pozy.ai/docs/webhooks/events#post-authenticity-changed) | Post authenticity changed. | | [`post.external.created`](https://pozy.ai/docs/webhooks/events#post-external-created) | External post created. | | [`post.external.updated`](https://pozy.ai/docs/webhooks/events#post-external-updated) | External post updated. | | [`post.external.deleted`](https://pozy.ai/docs/webhooks/events#post-external-deleted) | External post deleted. | --- # Post lifecycle Every post status and per-platform status, how a post moves between them, what you can do in each, and which webhook fires on each change. A post has one overall `status` and one `status` per entry in `platforms`. The overall status is a summary of the entries: a post is `published` only when every entry published, and `partial` when some did and some did not. This page lists every status, the moves between them and the webhook that tells you about each move. ## Overall statuses | Status | What it means | | --- | --- | | `draft` | Saved, not going anywhere. The default when you send no scheduling field. | | `pending_approval` | Saved with `requiresApproval: true`. It never publishes until someone approves it. | | `scheduled` | Has a future publish time, set by you or by a queue. | | `publishing` | Short-lived. Pozy is sending it to the platforms right now. | | `published` | Every entry published. | | `partial` | Some entries published, at least one failed. | | `failed` | Every entry failed. | | `cancelled` | Nothing will publish: either its account was disconnected before the publish time, or you removed every published copy with [unpublish](https://pozy.ai/docs/publishing/editing#take-a-post-down). | ## How a post moves The main path is short: ```text draft ──► scheduled ──► publishing ──┬──► published ├──► partial └──► failed ``` Everything else is a side door into or out of that path: | From | To | What moves it | | --- | --- | --- | | `draft` | `scheduled` | An update with `isDraft: false` plus a time, `publishNow` or `queuedFromProfile`. | | `draft` | `pending_approval` | An update with `requiresApproval: true` and a time. | | `pending_approval` | `scheduled` | An approval. If the time has passed, it publishes within a couple of minutes. | | `pending_approval` | `draft` | A rejection. | | `scheduled` | `cancelled` | The account was disconnected before the publish time. | | `failed`, `partial` | `scheduled` | A retry. Only the failed entries go out again. | | `published`, `partial` | `cancelled` | Every published copy was removed with unpublish. Unpublishing some copies leaves the post `partial`. | | `cancelled` | `scheduled` | An update with a new time. A cancelled post is editable like a draft. | A `publishNow` post, or one whose `scheduledFor` is already in the past, goes straight from the request to `publishing` and comes back as `published`, `partial` or `failed`. ## Per-platform statuses Each entry in `post.platforms` has its own status: | Entry status | What it means | | --- | --- | | `pending` | Waiting for the publish time. | | `processing` | Media is being prepared for this platform. | | `uploading` | Pozy is uploading to the platform. Some platforms, like TikTok and YouTube, take minutes here. | | `published` | Live. `platformPostId`, `platformPostUrl` and `publishedAt` are set. | | `failed` | The platform refused it or it could not be sent. `errorMessage`, `errorCategory` and `errorSource` explain why. | | `cancelled` | Removed from the platform with unpublish, or cancelled before publishing. | An entry can be deleted on the platform after it published, for example when the owner removes it in the Instagram app. The entry keeps `status: "published"` and gains `removedFromPlatformAt`, and a `post.platform.deleted` webhook fires. Detection runs in the background roughly every hour, so expect some delay. ## Read the status ```bash cURL curl https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88 \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const entry of data.post.platforms) { console.log(entry.platform, entry.status, entry.errorCategory ?? ""); } ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, timeout=30, ) for entry in resp.json()["post"]["platforms"]: print(entry["platform"], entry["status"], entry.get("errorCategory", "")) ``` ```json { "post": { "_id": "6721a8c04e9b2f1d7c3a5e88", "status": "partial", "scheduledFor": "2026-11-03T08:30:00.000Z", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21", "status": "published", "platformPostUrl": "https://www.linkedin.com/feed/update/urn:li:share:7254471109021356032" }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "status": "failed", "errorMessage": "The video is too short to publish as a Reel.", "errorCategory": "user_content", "errorSource": "user" } ] } } ``` For anything that changes after creation, prefer webhooks to polling. Use `GET /v1/posts` with `updatedSince` to reconcile after downtime. ## Why an entry failed Failed entries carry a readable `errorMessage` and two fields you can branch on. `errorSource` says who can fix it, and `errorCategory` says what kind of problem it is. Some entries also carry `platformError` with the platform's own `code`, `subcode` and `message`. | `errorCategory` | What happened | What to do | | --- | --- | --- | | `auth_expired` | The account's access was revoked or expired. | Ask the owner to reconnect. [Account health](https://pozy.ai/docs/connect/account-health) spots this before a post fails. | | `user_content` | The post breaks a platform rule: length, format, media size or duration. | Fix the content, then retry or update the post. | | `user_abuse` | The platform flagged the activity as spam or too frequent. | Slow down and space posts out. | | `account_issue` | Something on the account blocks it, such as the wrong account type. | Fix it on the platform. | | `platform_rejected` | The platform refused the content on policy grounds. | Change the content. | | `platform_error` | The platform had an outage or a server error. | Retry later. | | `platform_rate_limit` | The platform is throttling requests. | Pozy retries these on its own. | | `quota_exhausted` | A daily platform quota ran out. | It works again after the platform's quota resets. | | `system_error` | Something failed on Pozy's side. | Retry. Contact support@pozy.ai if it keeps happening. | | `unknown` | Not classified. | Read `errorMessage`. | | `errorSource` | Meaning | | --- | --- | | `user` | You or your user must change something before a retry can work. | | `platform` | A platform-side problem. Retrying later usually works. | | `system` | A Pozy-side problem. Retrying usually works. | ## Retry a failed post `POST /v1/posts/{postId}/retry` publishes the failed entries of a `failed` or `partial` post again. Entries that already published are left alone, so a retry never double-posts. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/retry \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/retry", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/retry", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, timeout=60, ) print(resp.json()["post"]["status"]) ``` Fix `user` errors before you retry, otherwise the same error comes back. For a content fix, update the post with `PUT /v1/posts/{postId}` first, then retry. ## What you can do in each status | Action | Allowed when the post is | Notes | | --- | --- | --- | | [Update](https://pozy.ai/docs/publishing/editing) (`PUT /v1/posts/{postId}`) | `draft`, `scheduled`, `failed`, `partial`, `cancelled` | On a `published` post only the `recycling` settings can change. | | Approve or reject (`POST .../review`) | `pending_approval` | Any other status returns `409`. | | Retry (`POST .../retry`) | `failed`, `partial` | Only failed entries are sent again. | | Edit published text (`POST .../edit`) | `published`, `partial` | Text only, on platforms that allow edits. | | Unpublish (`POST .../unpublish`) | `published`, `partial` | Removes the copy from one platform. | | Delete (`DELETE /v1/posts/{postId}`) | Anything except `published` | Removes the record from Pozy. It does not touch the platform and sends no webhook. | ## Webhooks for each change | Event | Fires when | | --- | --- | | `post.scheduled` | The post enters `scheduled`: created with a time, added to a queue, a draft promoted, an approval, or a retry. Editing the time of a post that is already scheduled does not fire it again. | | `post.platform.published` | One entry published. Fires per entry, without waiting for the others. | | `post.platform.failed` | One entry failed for good. Errors Pozy is still retrying stay quiet. | | `post.published` | Every entry published. | | `post.partial` | Some entries published, others failed. | | `post.failed` | Every entry failed. | | `post.cancelled` | A scheduled post was cancelled because its account was disconnected and nothing had published. | | `post.recycled` | A recycling schedule created a new copy of a published post. | | `post.tiktok.url_resolved` | A published TikTok entry got its public URL. TikTok assigns it a few minutes after upload, so `post.published` can arrive with an empty TikTok URL. | | `post.platform.deleted` | A published entry was found deleted on the platform. | Exactly one of `post.published`, `post.partial` and `post.failed` fires each time a post publishes. Per-entry events usually arrive first, but do not rely on the order: see [Webhook idempotency and ordering](https://pozy.ai/docs/webhooks/idempotency). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/posts/{postId}`](https://pozy.ai/docs/api/getPost) | Get post | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | | [`POST /v1/posts/{postId}/retry`](https://pozy.ai/docs/api/retryPost) | Retry failed post | | [`POST /v1/posts/{postId}/review`](https://pozy.ai/docs/api/reviewPost) | Approve or reject a post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`DELETE /v1/posts/{postId}`](https://pozy.ai/docs/api/deletePost) | Delete post | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.scheduled`](https://pozy.ai/docs/webhooks/events#post-scheduled) | Post scheduled. | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.partial`](https://pozy.ai/docs/webhooks/events#post-partial) | Post partial. | | [`post.cancelled`](https://pozy.ai/docs/webhooks/events#post-cancelled) | Post cancelled. | | [`post.recycled`](https://pozy.ai/docs/webhooks/events#post-recycled) | Post recycled. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`post.tiktok.url_resolved`](https://pozy.ai/docs/webhooks/events#post-tiktok-url-resolved) | TikTok post URL resolved. | --- # Scheduling and timezones Schedule posts at a local wall-clock time with scheduledFor and an IANA timezone, give each platform its own time, and reschedule safely. To publish later, send `scheduledFor`. Pozy stores the time as a UTC instant and publishes at that instant. The one thing to get right is how that instant is read from what you send, because a missing timezone is the most common reason a post goes out at the wrong hour. ## Schedule at a local time Send the wall-clock time the person picked, without an offset, plus their IANA timezone: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Doors open at 10. See you there.", "platforms": [{ "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22" }], "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_q8w1_doors.jpg" }], "scheduledFor": "2026-11-14T09:00:00", "timezone": "America/New_York" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Doors open at 10. See you there.", platforms: [{ platform: "instagram", accountId: "6710c3e2a9f14b0d8e5c7a22" }], mediaItems: [{ type: "image", url: "https://pozy.ai/media/temp/1730289600_q8w1_doors.jpg" }], scheduledFor: "2026-11-14T09:00:00", // In a browser: Intl.DateTimeFormat().resolvedOptions().timeZone timezone: "America/New_York", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.scheduledFor); // "2026-11-14T14:00:00.000Z" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Doors open at 10. See you there.", "platforms": [{"platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22"}], "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/temp/1730289600_q8w1_doors.jpg"}], "scheduledFor": "2026-11-14T09:00:00", "timezone": "America/New_York", }, timeout=30, ) print(resp.json()["post"]["scheduledFor"]) # 2026-11-14T14:00:00.000Z ``` The response returns `status: "scheduled"` and `scheduledFor` in UTC with a trailing `Z`. Convert it back to local time for display; Pozy never returns local times. ## How scheduledFor is read | You send | Pozy reads it as | | --- | --- | | `"2026-11-14T14:00:00Z"` | That UTC instant. `timezone` is ignored. | | `"2026-11-14T15:00:00+01:00"` | That instant at that offset. `timezone` is ignored. | | `"2026-11-14T09:00:00"` (no `Z`, no offset) | A wall-clock time in `timezone`. Without `timezone`, in UTC. | All three examples above are the same moment when `timezone` is `America/New_York` for the last one. > **A missing timezone means UTC:** > > If a person in Madrid picks 10:00 and you send only `"scheduledFor": "2026-11-14T10:00:00"`, the post goes out at 10:00 UTC, which is 11:00 in Madrid. Always send `timezone` with a local time, or convert to UTC yourself and send a `Z`. `timezone` must be a canonical IANA name such as `Europe/Madrid`, `America/Sao_Paulo` or `Asia/Tokyo`. Abbreviations like `CET` or `PST` and misspellings are rejected with a `400`. ## Give each platform its own time A `scheduledFor` on a `platforms[]` entry overrides the top-level one for that entry. The top-level `timezone` still applies to it. Here LinkedIn goes out at 08:30 and Instagram at 19:00, both New York time: ```json { "content": "Our November product update is out.", "scheduledFor": "2026-11-05T08:30:00", "timezone": "America/New_York", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "scheduledFor": "2026-11-05T19:00:00" } ] } ``` The overall `post.scheduledFor` shows the top-level time. Each entry's own time is on `platforms[].scheduledFor`. ## Daylight saving time Wall-clock times are converted with the offset in force on that date, so a 09:00 New York post is 14:00 UTC in winter and 13:00 UTC in summer. Two hours a year need care: | Case | Example (`America/New_York`) | What to do | | --- | --- | --- | | A time that does not exist (clocks jump forward) | `2027-03-14T02:30:00` | Pick another time, or send an explicit offset. | | A time that happens twice (clocks fall back) | `2027-11-07T01:30:00` | Send an explicit offset (`-04:00` or `-05:00`) to say which one you mean. | On those two days, sending a `Z` time or an explicit offset is the only way to be exact. Queue slots follow the wall clock all year: an 18:00 slot stays 18:00 local after the change. ## Times in the past A `scheduledFor` that has already passed is not an error. The post publishes during the request, exactly as if you had sent `publishNow: true`, and the response holds the outcome. If you build a scheduler that might send stale times, check the time yourself before calling. ## Reschedule a post Change the time with `PUT /v1/posts/{postId}`. This works for `scheduled`, `draft`, `failed`, `partial` and `cancelled` posts: ```bash cURL curl -X PUT https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledFor": "2026-11-15T09:00:00", "timezone": "America/New_York" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ scheduledFor: "2026-11-15T09:00:00", timezone: "America/New_York" }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.put( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"scheduledFor": "2026-11-15T09:00:00", "timezone": "America/New_York"}, timeout=30, ).raise_for_status() ``` Things to know when you reschedule: - **Drafts stay drafts.** To schedule a draft, send `isDraft: false` together with the time. A time alone is saved but the post keeps `status: "draft"`. - **Queued posts keep their queue.** If the post came from a queue and the new time is already taken by another post in that queue, the update fails with `409` and `code: "queue_slot_conflict"`. Pick a free time, drop `scheduledFor` to let the queue choose, or send `queueId: null` to take the post out of the queue. - **No new `post.scheduled` event.** Moving a post that is already scheduled does not fire the webhook again. Keep the new time from the update response. - **Cancel instead of moving.** To stop a scheduled post, delete it with `DELETE /v1/posts/{postId}` or turn it back into a draft with `isDraft: true`. ## Let Pozy pick the time Two features choose `scheduledFor` for you: - **Queues** spread posts over recurring weekly slots per profile. See [Queues](https://pozy.ai/docs/publishing/queues). - **Best time to post** suggests slots from each account's own engagement history. See [Best time to post](https://pozy.ai/docs/analytics/best-time). ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`GET /v1/posts/{postId}`](https://pozy.ai/docs/api/getPost) | Get post | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | --- # Queues and recycling Give a profile recurring weekly posting slots, add posts to the next free slot with queuedFromProfile, and republish evergreen posts on a schedule. A queue is a weekly timetable for a profile: Monday 09:00, Wednesday 09:00, Friday 17:30. Instead of picking a time for every post, you add posts to the queue and each one takes the next free slot. Fifty posts become fifty slots without a single `scheduledFor`. Queues belong to [profiles](https://pozy.ai/docs/build/profiles), not to accounts. A post added to a profile's queue publishes to whichever accounts you list in its `platforms`. ## Create a queue `POST /v1/queue/slots` takes a profile, a name, a timezone and the weekly slots. `dayOfWeek` runs from `0` (Sunday) to `6` (Saturday) and `time` is `HH:mm` in 24-hour format, read in the queue's timezone. ```bash cURL curl -X POST https://api.pozy.ai/v1/queue/slots \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "670f9b1d4c2e8a7f3b6d5e10", "name": "Weekday mornings", "timezone": "Europe/London", "slots": [ { "dayOfWeek": 1, "time": "08:45" }, { "dayOfWeek": 3, "time": "08:45" }, { "dayOfWeek": 5, "time": "12:30" } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/queue/slots", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "670f9b1d4c2e8a7f3b6d5e10", name: "Weekday mornings", timezone: "Europe/London", slots: [ { dayOfWeek: 1, time: "08:45" }, { dayOfWeek: 3, time: "08:45" }, { dayOfWeek: 5, time: "12:30" }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.schedule._id, data.nextSlots); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/queue/slots", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "670f9b1d4c2e8a7f3b6d5e10", "name": "Weekday mornings", "timezone": "Europe/London", "slots": [ {"dayOfWeek": 1, "time": "08:45"}, {"dayOfWeek": 3, "time": "08:45"}, {"dayOfWeek": 5, "time": "12:30"}, ], }, timeout=30, ) queue = resp.json() print(queue["schedule"]["_id"], queue["nextSlots"]) ``` ```json { "success": true, "schedule": { "_id": "6722f1a93b0c4d5e6f7a8b90", "profileId": "670f9b1d4c2e8a7f3b6d5e10", "name": "Weekday mornings", "timezone": "Europe/London", "slots": [ { "dayOfWeek": 1, "time": "08:45" }, { "dayOfWeek": 3, "time": "08:45" }, { "dayOfWeek": 5, "time": "12:30" } ], "active": true, "isDefault": true }, "nextSlots": ["2026-11-02T08:45:00+00:00", "2026-11-04T08:45:00+00:00"] } ``` The first queue a profile gets becomes its default. A profile can have several queues, for example one for product news and one for weekend content. Create another with the same `profileId` and a different `name`. ## Add a post to the queue Create the post with `queuedFromProfile` set to the profile id and no `scheduledFor`. Pozy locks the next free slot for it, so the next post you add takes the slot after that. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Tip of the week: export any report as CSV from the share menu.", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }, { "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23" } ], "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Tip of the week: export any report as CSV from the share menu.", platforms: [ { platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21" }, { platform: "twitter", accountId: "6710c3e2a9f14b0d8e5c7a23" }, ], queuedFromProfile: "670f9b1d4c2e8a7f3b6d5e10", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.scheduledFor, data.post.queueId); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Tip of the week: export any report as CSV from the share menu.", "platforms": [ {"platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21"}, {"platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23"}, ], "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10", }, timeout=30, ) post = resp.json()["post"] print(post["scheduledFor"], post["queueId"]) ``` The post comes back `scheduled`, with the slot's time in `scheduledFor` (UTC) and the queue in `queueId`. Add `queueId` next to `queuedFromProfile` to target a queue other than the default one. > **Do not copy the next slot into scheduledFor:** > > `GET /v1/queue/next-slot` is for showing people when a post would go out. If you read it and then send that time as `scheduledFor`, two posts created at the same moment can land on the same slot. Send `queuedFromProfile` and let Pozy assign the slot. ## Preview upcoming slots | Call | Returns | | --- | --- | | `GET /v1/queue/next-slot?profileId=...` | The next free slot, its timezone and the queue it belongs to. `404` when the profile has no queue or no free slot. | | `GET /v1/queue/preview?profileId=...&count=10` | The next `count` slot times (up to 100) as ISO date-times. | | `GET /v1/queue/slots?profileId=...` | The default queue with its slots and next times. Add `all=true` for every queue of the profile, or `queueId` for one. | ## Change or remove a queue `PUT /v1/queue/slots` replaces a queue's slots, name, timezone or active state. Without `queueId` it changes the default queue; with `queueId` it changes that one. | Field | What it does | | --- | --- | | `slots`, `timezone` | Required. The full new timetable. | | `name` | Rename the queue. | | `active` | `false` pauses the queue. `next-slot` then returns `400` and new posts cannot join it. | | `setAsDefault` | `true` makes this queue the profile's default. | | `reshuffleExisting` | `true` moves posts already waiting in the queue onto the new timetable. The response reports `reshuffledCount`. | `DELETE /v1/queue/slots?profileId=...&queueId=...` deletes one queue. Leave out `queueId` to delete every queue of the profile. Deleting the default queue promotes another one to default. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` on create or update | A slot with `time` not in `HH:mm`, a `dayOfWeek` outside 0 to 6, or an unknown timezone. | Fix the slot or use a canonical IANA name. | | `404` on `next-slot` | The profile has no queue, or every slot is taken. | Create a queue or add slots. | | `409` `queue_slot_conflict` on `PUT /v1/posts/{postId}` | You moved a queued post onto a time another post in the same queue already holds. | Pick another time, drop `scheduledFor`, or send `queueId: null` to schedule it outside the queue. | ## Recycle evergreen posts Recycling republishes a post on a repeating schedule, which suits evergreen content such as guides, FAQs or a pinned offer. Set `recycling` when you create or update the post: ```json { "content": "New here? Our 5-minute setup guide covers everything.", "platforms": [{ "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }], "scheduledFor": "2026-11-02T09:00:00", "timezone": "Europe/London", "recycling": { "enabled": true, "gap": 2, "gapFreq": "month", "expireCount": 6, "contentVariations": [ "New here? Our 5-minute setup guide covers everything.", "Setting up a new workspace? Start with the 5-minute guide.", "The fastest way to get started is still the 5-minute setup guide." ] } } ``` | Field | What it does | | --- | --- | | `enabled` | Turns recycling on or off. Defaults to `true` when you send `recycling`. | | `gap` and `gapFreq` | How long to wait between copies: `gap` weeks or months (`gapFreq` is `week` or `month`, default `month`). | | `startDate` | When the first copy may go out. | | `expireCount` | Stop after this many copies. `null` for no limit. | | `expireDate` | Stop after this date. `null` for no limit. | | `contentVariations` | Texts to rotate through, so followers do not see the same words every time. | Each copy is a new post with its own id. It records the original in `recycledFromPostId`, fires `post.recycled`, then follows the normal lifecycle with its own `post.scheduled` and publish events. The original post shows progress in `recycling.recycleCount`, `recycling.nextRecycleAt` and `recycling.lastRecycledAt`. Recycling is the one thing you can still change on a published post: send a new `recycling` object with `PUT /v1/posts/{postId}`, or `"enabled": false` to stop it. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/queue/slots`](https://pozy.ai/docs/api/listQueueSlots) | List schedules | | [`POST /v1/queue/slots`](https://pozy.ai/docs/api/createQueueSlot) | Create schedule | | [`PUT /v1/queue/slots`](https://pozy.ai/docs/api/updateQueueSlot) | Update schedule | | [`DELETE /v1/queue/slots`](https://pozy.ai/docs/api/deleteQueueSlot) | Delete schedule | | [`GET /v1/queue/preview`](https://pozy.ai/docs/api/previewQueue) | Preview upcoming slots | | [`GET /v1/queue/next-slot`](https://pozy.ai/docs/api/getNextQueueSlot) | Get next available slot | ## Webhook events | Event | When it fires | | --- | --- | | [`post.scheduled`](https://pozy.ai/docs/webhooks/events#post-scheduled) | Post scheduled. | | [`post.recycled`](https://pozy.ai/docs/webhooks/events#post-recycled) | Post recycled. | --- # Drafts and approvals Save posts as drafts, hold them for review with requiresApproval, and approve or reject them with one call before anything goes live. Not every post should publish the moment it is written. A **draft** is saved and goes nowhere until you schedule it. A post that **requires approval** has a time but waits in `pending_approval` until someone with the right access approves it. Both are ordinary posts, so they show up in `GET /v1/posts` and can be edited like any other. ## Save a draft Send `isDraft: true`, or simply leave out every scheduling field. `platforms` is optional for drafts, so you can save the text and media before anyone has chosen the accounts. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Draft: holiday opening hours. Confirm with the store team.", "isDraft": true, "tags": ["holiday", "needs-review"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Draft: holiday opening hours. Confirm with the store team.", isDraft: true, tags: ["holiday", "needs-review"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post._id, data.post.status); // "draft" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Draft: holiday opening hours. Confirm with the store team.", "isDraft": True, "tags": ["holiday", "needs-review"], }, timeout=30, ) print(resp.json()["post"]["status"]) # draft ``` `isDraft: true` wins over `publishNow`, `scheduledFor` and `queuedFromProfile`, so a draft can never publish by accident. Drafts skip most platform checks, which is why an incomplete post saves without errors. The checks run when you schedule it. List drafts with `GET /v1/posts?status=draft`, optionally filtered by `profileId`, `tags` or `search`. ## Turn a draft into a scheduled post Update the post with `isDraft: false` and one of `scheduledFor`, `publishNow` or `queuedFromProfile`. Add `platforms` if the draft had none. ```bash cURL curl -X PUT https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "isDraft": false, "content": "Holiday hours: open 10:00 to 16:00 on 24 and 31 December.", "platforms": [ { "platform": "facebook", "accountId": "6710c3e2a9f14b0d8e5c7a25" }, { "platform": "googlebusiness", "accountId": "6710c3e2a9f14b0d8e5c7a26" } ], "scheduledFor": "2026-12-20T09:00:00", "timezone": "Europe/Madrid" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ isDraft: false, content: "Holiday hours: open 10:00 to 16:00 on 24 and 31 December.", platforms: [ { platform: "facebook", accountId: "6710c3e2a9f14b0d8e5c7a25" }, { platform: "googlebusiness", accountId: "6710c3e2a9f14b0d8e5c7a26" }, ], scheduledFor: "2026-12-20T09:00:00", timezone: "Europe/Madrid", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.put( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "isDraft": False, "content": "Holiday hours: open 10:00 to 16:00 on 24 and 31 December.", "platforms": [ {"platform": "facebook", "accountId": "6710c3e2a9f14b0d8e5c7a25"}, {"platform": "googlebusiness", "accountId": "6710c3e2a9f14b0d8e5c7a26"}, ], "scheduledFor": "2026-12-20T09:00:00", "timezone": "Europe/Madrid", }, timeout=30, ).raise_for_status() ``` > **Note:** > > If you send a time without `isDraft: false`, the update succeeds but the post stays a draft. This is deliberate: an editor changing the suggested time of a draft should not publish it. Scheduling runs the full platform validation (media rules, required fields, text limits). A post that fails it returns `400` and stays a draft. Run [validation](https://pozy.ai/docs/publishing/validation) first if you want to show problems before the person presses Schedule. ## Hold a post for approval Add `requiresApproval: true` and a time. The post is saved as `pending_approval` and will not publish, even when its time comes, until it is approved. ```json { "content": "Flash sale: 30% off annual plans until Sunday.", "platforms": [ { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22" }, { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" } ], "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_z1x5_sale.jpg" }], "scheduledFor": "2026-11-20T10:00:00", "timezone": "Europe/Madrid", "requiresApproval": true } ``` A common setup: writers and AI agents create posts with keys that always set `requiresApproval`, and a person reviews the list each morning with `GET /v1/posts?status=pending_approval`. ## Approve or reject `POST /v1/posts/{postId}/review` takes one field, `decision`, which is `approve` or `reject`: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/review \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "decision": "approve" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/review", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ decision: "approve" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status); // "scheduled" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/review", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"decision": "approve"}, timeout=30, ) print(resp.json()["post"]["status"]) # scheduled ``` | Decision | Result | | --- | --- | | `approve` | The post becomes `scheduled` for its `scheduledFor` and `post.scheduled` fires. If that time has already passed, it publishes within a couple of minutes. | | `reject` | The post goes back to `draft`. Edit it and send it for approval again, or delete it. | Reviewing a post that is not `pending_approval` returns `409`. Approval does not fire `post.scheduled` at creation time, only when the post is approved. ## Who can create, approve and publish | Caller | Create drafts and posts | Approve or reject | Notes | | --- | --- | --- | --- | | Workspace owner, admin or member | Yes | Yes | From the dashboard or the MCP server. | | Workspace viewer | No | No | Read-only. | | Read-write API key | Yes | Yes | Restrict it to some profiles with a [profile-scoped key](https://pozy.ai/docs/build/tenant-keys). | | Read-only API key | No | No | Writes return `403` with `code: "read_only_key"`. | To keep approval with people only, give automated writers a key that is scoped to the right profiles and always send `requiresApproval: true` from your own code. Keys cannot be forced to send it, so treat it as a rule in your integration. ## Platform drafts are different Two platforms have a draft of their own, separate from Pozy drafts: - **TikTok:** `tiktokSettings.draft: true` uploads the video or photos to the creator's TikTok inbox, where they finish and post it in the app. - **Facebook:** `facebookSettings.draft: true` creates an unpublished Page post. For Pozy these are published posts: the upload succeeded. See [TikTok](https://pozy.ai/docs/platforms/tiktok) and [Facebook](https://pozy.ai/docs/platforms/facebook). ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`POST /v1/posts/{postId}/review`](https://pozy.ai/docs/api/reviewPost) | Approve or reject a post | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | | [`DELETE /v1/posts/{postId}`](https://pozy.ai/docs/api/deletePost) | Delete post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.scheduled`](https://pozy.ai/docs/webhooks/events#post-scheduled) | Post scheduled. | --- # Media uploads Upload images, videos and documents with a presigned URL, attach them to posts with mediaItems, and add alt text, thumbnails and captions. Posts carry media as URLs in `mediaItems`. If your file is already on a public HTTPS URL, use that URL directly. If it lives on your server or the user's device, upload it to Pozy first: you ask for an upload URL, send the file there, and put the returned public URL in the post. One upload can be reused by every platform in a post and by later posts. ## Upload a file ### Ask for an upload URL `POST /v1/media/presign` takes the file name and its MIME type. Add `size` in bytes to check the 5 GB limit before you upload. ```bash cURL curl -X POST https://api.pozy.ai/v1/media/presign \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "demo.mp4", "contentType": "video/mp4", "size": 48213504 }' ``` ```ts Node const uploadRes = await fetch("https://api.pozy.ai/v1/media/presign", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ filename: "demo.mp4", contentType: "video/mp4", size: 48213504 }), }); const upload = await uploadRes.json(); if (!uploadRes.ok) throw new Error(upload.error); ``` ```python Python upload = requests.post( "https://api.pozy.ai/v1/media/presign", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"filename": "demo.mp4", "contentType": "video/mp4", "size": 48213504}, timeout=30, ).json() ``` ```json { "uploadUrl": "https://…signed-upload-url…", "publicUrl": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4", "key": "temp/1730289600_r7t2_demo.mp4", "expiresIn": 3600 } ``` `uploadUrl` is valid for `expiresIn` seconds (one hour). `publicUrl` is the address you put in the post. ### PUT the file Send the raw bytes to `uploadUrl` with the same `Content-Type` you declared. Do not add an `Authorization` header: the signature in the URL is the permission. ```bash cURL curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: video/mp4" \ --data-binary @demo.mp4 ``` ```ts Node import { readFile } from "node:fs/promises"; await fetch(upload.uploadUrl, { method: "PUT", headers: { "Content-Type": "video/mp4" }, body: await readFile("demo.mp4"), }); ``` ```python Python with open("demo.mp4", "rb") as f: requests.put(upload["uploadUrl"], data=f, headers={"Content-Type": "video/mp4"}, timeout=600).raise_for_status() ``` A successful upload answers `200` with an empty body. ### Attach it to a post Put `publicUrl` in `mediaItems`: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Two minutes, start to finish.", "mediaItems": [{ "type": "video", "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4" }], "platforms": [ { "platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": { "title": "Pozy in two minutes" } }, { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" } ], "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Two minutes, start to finish.", mediaItems: [{ type: "video", url: upload.publicUrl }], platforms: [ { platform: "youtube", accountId: "6710c3e2a9f14b0d8e5c7a27", platformSpecificData: { title: "Pozy in two minutes" } }, { platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21" }, ], queuedFromProfile: "670f9b1d4c2e8a7f3b6d5e10", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Two minutes, start to finish.", "mediaItems": [{"type": "video", "url": upload["publicUrl"]}], "platforms": [ {"platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": {"title": "Pozy in two minutes"}}, {"platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21"}, ], "queuedFromProfile": "670f9b1d4c2e8a7f3b6d5e10", }, timeout=30, ).raise_for_status() ``` ## Uploads through the API: about 4 MB A request to the API itself can carry about 4 MB. Endpoints that take the file in the request body are limited by that: [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect), porting and identity documents, SMS opt-in screenshots, RCS logos and banners, the WhatsApp profile photo and CSV bulk uploads. A larger body is refused with `413` before Pozy reads it, without the usual JSON error. Use `upload-direct` for files up to 4 MB. For anything larger, use `POST /v1/media/presign` and send the file straight to storage as shown above: presigned uploads take files up to 5 GB. ## Keep uploads alive Uploaded files sit in temporary storage for **7 days**. When a post that uses the file is created or updated, Pozy copies it to permanent storage, so a post scheduled months ahead keeps its media. The catch is the gap between upload and post. If your app uploads when the person picks a file but creates the post a week later, the file is gone. A draft still saves, but scheduling or publishing it fails with `400`. Either create the post soon after the upload, or upload when the post is created. ## Accepted file types | Kind | Formats | `contentType` values | | --- | --- | --- | | Images | JPEG, PNG, WebP, GIF | `image/jpeg`, `image/png`, `image/webp`, `image/gif` | | Videos | MP4, MPEG, MOV, AVI, WebM, M4V | `video/mp4`, `video/mpeg`, `video/quicktime`, `video/avi`, `video/x-msvideo`, `video/webm`, `video/x-m4v` | | Documents | PDF (LinkedIn document posts) | `application/pdf` | | Audio | MP3, M4A, AAC, OGG, WAV, WebM | `audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm` | | Captions | SRT, WebVTT | `application/x-subrip`, `text/vtt` | Every type can be up to 5 GB at upload. Platforms are much stricter, and each has its own rules for count, size, duration and aspect ratio: see the media section of each [platform page](https://pozy.ai/docs/platforms). Audio cannot be posted on its own. Use it as an attachment in [inbox messages](https://pozy.ai/docs/inbox/messages). A presign request with a type outside this list, such as `application/octet-stream`, returns `400` with `code: "invalid_field_value"` and `param: "contentType"`. ## Media item fields | Field | What it does | | --- | --- | | `type` | `image`, `video`, `gif` or `document`. Pozy infers it from the file extension when you leave it out, and rejects a `type` that contradicts the extension. | | `url` | Public HTTPS URL that returns the file itself. | | `altText` | Accessibility text for images. Used where the platform supports it, for example Instagram (cut at 1,000 characters) and Bluesky. | | `thumbnail` | Custom cover for a video, JPG or PNG up to 10 MB. Used for Facebook videos and Reels, YouTube and LinkedIn videos. | | `instagramThumbnail` | Cover image for an Instagram Reel. | | `subtitles` | Up to 20 caption files for a video, each `{ "url": "...", "language": "en" }`. | | `title`, `filename`, `size`, `mimeType` | Optional details stored with the item. | Order matters: items are published in the order you list them, which sets the order of a carousel. ## Captions on videos Attach SRT or WebVTT files, one per language, and Pozy converts them for each platform that accepts captions: ```json [ { "type": "video", "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4", "subtitles": [ { "url": "https://pozy.ai/media/temp/1730289600_c1a9_demo.en.vtt", "language": "en" }, { "url": "https://pozy.ai/media/temp/1730289600_c1b3_demo.es.srt", "language": "es" } ] } ] ``` Caption files must be public and under 1 MB. A platform that cannot take a track skips it and adds a warning to the response. A file Pozy cannot parse returns `400`. ## Different media per platform `mediaItems` applies to every entry. Use `customMedia` on one `platforms[]` entry to replace it there, for example a square crop for Instagram and a wide one elsewhere: ```json { "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_k3f9_wide.jpg" }], "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21" }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "customMedia": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_m2p7_square.jpg" }] } ] } ``` ## Prepare a file for a platform When you are not sure a file meets a platform's rules, `POST /v1/media/prepare` runs the same conversion Pozy applies before publishing and gives you the result. Send the file URL and a `target`: ```json { "url": "https://pozy.ai/media/temp/1730289600_r7t2_demo.mp4", "target": "instagram" } ``` `target` is one of `instagram`, `instagram_story`, `facebook`, `facebook_reel`, `facebook_story`, `threads`, `tiktok`, `linkedin`, `twitter`, `bluesky`, `pinterest`, `reddit`, `snapchat`, `telegram`, `googlebusiness` or `youtube_thumbnail`. The response says whether anything `changed`, and gives the new `url` with `originalSize` and `outputSize`. Images are converted to a format the platform accepts and compressed under its limit; videos are re-encoded or resized when needed. The call is synchronous, so a long video can take a few minutes. A workspace can prepare up to 60 files per hour; past that you get `429`. ## URLs that fail The most common media failure is a URL that does not return the file. Links to Google Drive, Dropbox, OneDrive or iCloud pages return an HTML page, and platforms cannot publish a web page. Open the URL in a private browser window: if you see a page instead of the image or video, upload the file to Pozy instead. `POST /v1/tools/validate/media` checks a URL for you and reports its type, size and how it compares with each platform's limit ([Validation](https://pozy.ai/docs/publishing/validation)). | Error | Cause | Fix | | --- | --- | --- | | `400` on presign | Missing `filename`, a `contentType` outside the list, or `size` over 5 GB. | Send the real MIME type. | | Upload refused by storage | `uploadUrl` is more than an hour old, or the `Content-Type` differs from the presign call. | Ask for a new URL and send the same type. | | `400` when scheduling | The upload is more than 7 days old and was never attached to a post. | Upload again. | | Entry `failed` with `user_content` | The file breaks a platform rule (duration, size, ratio). | Check the platform page, or run `prepare` first. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/media/presign`](https://pozy.ai/docs/api/getMediaPresignedUrl) | Get upload URL | | [`POST /v1/media/prepare`](https://pozy.ai/docs/api/prepareMedia) | Prepare media for a platform | | Endpoint | What it does | | --- | --- | | [`POST /v1/tools/validate/media`](https://pozy.ai/docs/api/validateMedia) | Validate media URL | --- # Platform settings Set per-platform options with platformSpecificData, apply TikTok and Facebook settings to every entry at once, and know which value wins. Most of a post is shared: the text, the media, the time. Some options only make sense on one platform, like a YouTube title, a Pinterest board or an Instagram collaborator. Those go in `platformSpecificData` on that platform's entry in `platforms`. ## Set an option on one platform The shape of `platformSpecificData` depends on the entry's `platform`. Here the YouTube entry gets a title and a playlist, and the Pinterest entry gets a board and a link: ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "How we cut our build times in half.", "mediaItems": [{ "type": "video", "url": "https://pozy.ai/media/temp/1730289600_b5n8_builds.mp4" }], "scheduledFor": "2026-11-06T16:00:00Z", "platforms": [ { "platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": { "title": "Halving our build times", "visibility": "public", "playlistId": "PLx8b2kq0Engineering", "firstComment": "Chapters are in the description." } }, { "platform": "pinterest", "accountId": "6710c3e2a9f14b0d8e5c7a28", "platformSpecificData": { "boardId": "1093475830219481234", "title": "Halving build times", "link": "https://example.com/blog/build-times" } } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "How we cut our build times in half.", mediaItems: [{ type: "video", url: "https://pozy.ai/media/temp/1730289600_b5n8_builds.mp4" }], scheduledFor: "2026-11-06T16:00:00Z", platforms: [ { platform: "youtube", accountId: "6710c3e2a9f14b0d8e5c7a27", platformSpecificData: { title: "Halving our build times", visibility: "public", playlistId: "PLx8b2kq0Engineering", firstComment: "Chapters are in the description.", }, }, { platform: "pinterest", accountId: "6710c3e2a9f14b0d8e5c7a28", platformSpecificData: { boardId: "1093475830219481234", title: "Halving build times", link: "https://example.com/blog/build-times", }, }, ], }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "How we cut our build times in half.", "mediaItems": [{"type": "video", "url": "https://pozy.ai/media/temp/1730289600_b5n8_builds.mp4"}], "scheduledFor": "2026-11-06T16:00:00Z", "platforms": [ { "platform": "youtube", "accountId": "6710c3e2a9f14b0d8e5c7a27", "platformSpecificData": { "title": "Halving our build times", "visibility": "public", "playlistId": "PLx8b2kq0Engineering", "firstComment": "Chapters are in the description.", }, }, { "platform": "pinterest", "accountId": "6710c3e2a9f14b0d8e5c7a28", "platformSpecificData": { "boardId": "1093475830219481234", "title": "Halving build times", "link": "https://example.com/blog/build-times", }, }, ], }, timeout=30, ).raise_for_status() ``` ## The fields you will use most This is a short list per platform. Each platform page documents every field, with limits and examples. | Platform | Common `platformSpecificData` fields | Full reference | | --- | --- | --- | | Instagram | `contentType` (`story`), `firstComment`, `collaborators`, `userTags`, `shareToFeed`, `reelCover`, `audioName`, `isPaidPartnership`, `locationId` | [Instagram](https://pozy.ai/docs/platforms/instagram) | | TikTok | `privacyLevel`, `allowComment`, `allowDuet`, `allowStitch`, `contentPreviewConfirmed`, `expressConsentGiven`, `commercialContentType`, `mediaType`, `videoCoverTimestampMs`, `draft` | [TikTok](https://pozy.ai/docs/platforms/tiktok) | | YouTube | `title` (100 chars), `visibility`, `madeForKids`, `categoryId`, `playlistId`, `firstComment`, `containsSyntheticMedia`, `notifySubscribers` | [YouTube](https://pozy.ai/docs/platforms/youtube) | | Facebook | `contentType` (`story`, `reel`), `title`, `firstComment`, `pageId`, `geoRestriction`, `facebookSettings` | [Facebook](https://pozy.ai/docs/platforms/facebook) | | LinkedIn | `firstComment`, `documentTitle`, `organizationUrn`, `disableLinkPreview`, `poll`, `reshareUrl`, `audience`, `geoRestriction` | [LinkedIn](https://pozy.ai/docs/platforms/linkedin) | | X (`twitter`) | `threadItems`, `replyToTweetId`, `quoteTweetId`, `replySettings`, `poll`, `longVideo`, `article`, `madeWithAi`, `paidPartnership` | [X](https://pozy.ai/docs/platforms/x) | | Threads | `threadItems`, `topic_tag` (50 chars), `firstComment` (500 chars) | [Threads](https://pozy.ai/docs/platforms/threads) | | Pinterest | `boardId`, `boardSectionId`, `title` (100 chars), `link`, `coverImageUrl`, `isAiGenerated` | [Pinterest](https://pozy.ai/docs/platforms/pinterest) | | Bluesky | `threadItems`, `langs` | [Bluesky](https://pozy.ai/docs/platforms/bluesky) | | Google Business Profile | `topicType` (`STANDARD`, `EVENT`, `OFFER`), `callToAction`, `event`, `offer`, `locationId`, `languageCode` | [Google Business Profile](https://pozy.ai/docs/platforms/google-business-profile) | | Snapchat | `contentType` (`story`, `saved_story`, `spotlight`) | [Snapchat](https://pozy.ai/docs/platforms/snapchat) | | Telegram | `parseMode`, `disableWebPagePreview`, `disableNotification`, `protectContent` | [Telegram](https://pozy.ai/docs/platforms/telegram) | | Discord | `channelId` (required), `embeds`, `poll`, `crosspost`, `forumThreadName`, `forumAppliedTags` | [Discord](https://pozy.ai/docs/platforms/discord) | | Slack | `threadTs`, `unfurlLinks`, `unfurlMedia`, `username`, `iconUrl` | [Slack](https://pozy.ai/docs/platforms/slack) | | Reddit | `subreddit`, `title`, `url`, `flairId`, `nsfw`, `spoiler` | [Reddit](https://pozy.ai/docs/platforms/reddit) (publishing paused) | `geoRestriction` (Facebook, LinkedIn, X) takes `{ "countries": ["ES", "PT"] }`, up to 25 two-letter country codes, and limits who can see the post. ## Settings for every TikTok or Facebook entry TikTok and Facebook settings can also go at the top level of the body, as `tiktokSettings` and `facebookSettings`. They are copied into every entry of that platform, which saves repeating them when you post to several TikTok accounts at once: ```json { "platforms": [ { "platform": "tiktok", "accountId": "6710c3e2a9f14b0d8e5c7a24" }, { "platform": "tiktok", "accountId": "6710c3e2a9f14b0d8e5c7a29", "platformSpecificData": { "privacyLevel": "MUTUAL_FOLLOW_FRIENDS" } } ], "tiktokSettings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComment": true, "allowDuet": false, "allowStitch": false, "contentPreviewConfirmed": true, "expressConsentGiven": true } } ``` The merge is key by key and **the entry wins**. In the example, the second account keeps `MUTUAL_FOLLOW_FRIENDS` and takes every other key from `tiktokSettings`. > **TikTok needs explicit consent:** > > TikTok only publishes when `contentPreviewConfirmed` and `expressConsentGiven` are `true` and `privacyLevel` is one of the values the account allows. None of the three has a default. Your app must show TikTok's preview and consent step to the person posting before you set them. On Facebook, `draft`, `carouselCards`, `carouselLink` and `textFormatPresetId` live inside `facebookSettings`. Every other Facebook field sits directly on `platformSpecificData`. ## Rules that decide which value wins | Situation | What happens | | --- | --- | | `customContent`, `customMedia` or `scheduledFor` on an entry | Replaces the top-level `content`, `mediaItems` or `scheduledFor` for that entry only. | | `tiktokSettings` or `facebookSettings` at the top level | Merged into each matching entry. Keys set on the entry are kept. | | A field on the wrong entry, or `platformSpecificData` at the top level | Ignored. The post is still saved, and the response lists what was ignored in `warnings`. | | `PUT /v1/posts/{postId}` with a settings object | The object you send replaces the stored one whole. Send every key you want to keep. Settings objects you leave out are kept as they were. | > **Tip:** > > A misplaced option never fails the request, so it is easy to miss. Log `warnings` from every create and update while you build your integration. ## When a value is rejected A value the platform cannot accept fails the whole request with `400` and nothing is created. `param` gives the exact path of the bad field: ```json { "error": "title must be at most 100 characters", "type": "invalid_request_error", "code": "invalid_field_value", "param": "platforms[0].platformSpecificData.title" } ``` Run the same body through `POST /v1/tools/validate/post` to catch these before you create anything ([Validation](https://pozy.ai/docs/publishing/validation)). ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`POST /v1/tools/validate/post`](https://pozy.ai/docs/api/validatePost) | Validate post content | --- # Threads Publish a chain of connected posts on X, Threads and Bluesky with threadItems, each with its own text and media. A thread is a chain of posts where each one replies to the one before it. X, Threads and Bluesky support them through the same field: `threadItems` in that entry's `platformSpecificData`. Each item has its own `content` and optional `mediaItems`. ## Publish a thread on X ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "What we learned moving 2 million jobs to a new queue", "scheduledFor": "2026-11-04T15:00:00Z", "platforms": [{ "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23", "platformSpecificData": { "threadItems": [ { "content": "We moved 2 million background jobs to a new queue last month. Here is what broke, and what we would do again. 🧵", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_t4h1_queue-graph.png" }] }, { "content": "1. Retries were the real load. 40% of our traffic was jobs retrying the same failure." }, { "content": "2. Idempotency keys on every job made the cutover boring, which is the goal." }, { "content": "3. Ship the dashboard before the migration, not after. Full write-up: https://example.com/blog/queues" } ] } }] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "What we learned moving 2 million jobs to a new queue", scheduledFor: "2026-11-04T15:00:00Z", platforms: [ { platform: "twitter", accountId: "6710c3e2a9f14b0d8e5c7a23", platformSpecificData: { threadItems: [ { content: "We moved 2 million background jobs to a new queue last month. Here is what broke, and what we would do again. 🧵", mediaItems: [{ type: "image", url: "https://pozy.ai/media/temp/1730289600_t4h1_queue-graph.png" }], }, { content: "1. Retries were the real load. 40% of our traffic was jobs retrying the same failure." }, { content: "2. Idempotency keys on every job made the cutover boring, which is the goal." }, { content: "3. Ship the dashboard before the migration, not after. Full write-up: https://example.com/blog/queues" }, ], }, }, ], }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "What we learned moving 2 million jobs to a new queue", "scheduledFor": "2026-11-04T15:00:00Z", "platforms": [{ "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23", "platformSpecificData": { "threadItems": [ { "content": "We moved 2 million background jobs to a new queue last month. Here is what broke, and what we would do again. 🧵", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/temp/1730289600_t4h1_queue-graph.png"}], }, {"content": "1. Retries were the real load. 40% of our traffic was jobs retrying the same failure."}, {"content": "2. Idempotency keys on every job made the cutover boring, which is the goal."}, {"content": "3. Ship the dashboard before the migration, not after. Full write-up: https://example.com/blog/queues"}, ] }, }], }, timeout=30, ).raise_for_status() ``` > **The top-level content is not published:** > > When an entry has `threadItems`, its first post is `threadItems[0]`. The top-level `content` is kept for the dashboard, search and your own lists, but it is not posted on that platform. Put the opening post in the first item. The entry's `platformPostUrl` points at the first post of the thread. ## Rules per platform | | X (`twitter`) | Threads | Bluesky | | --- | --- | --- | --- | | Field | `threadItems` | `threadItems` | `threadItems` | | Text per item | 280 characters on standard accounts, more with a paid X subscription | 500 characters | 300 characters | | Media per item | Up to 4 images, or 1 video, or 1 GIF | Images, a video or a carousel | Up to 4 images or 1 video | | Extras | A thread cannot carry a `poll`. Add `replyToTweetId` to post the whole thread as a reply. | `firstComment` replies to the first post of the thread. `topic_tag` applies to the thread. | `langs` applies to every item. | | Edit after publishing | Not for threads | No edit support | No edit support | X counts characters with weights: every link counts as 23 characters and most emoji count as 2. Check a long thread with `POST /v1/tools/validate/post-length` before scheduling ([Validation](https://pozy.ai/docs/publishing/validation)). ## One thread, three platforms Each entry carries its own `threadItems`, so you can split the same story differently for each platform's limit. A shorter LinkedIn version can sit next to them as a single post: ```json { "content": "Our queue migration, in short", "platforms": [ { "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23", "platformSpecificData": { "threadItems": [ { "content": "We moved 2 million jobs to a new queue. What broke, in three posts." }, { "content": "Retries were 40% of the load." }, { "content": "Idempotency keys made the cutover boring." } ] } }, { "platform": "threads", "accountId": "6710c3e2a9f14b0d8e5c7a2a", "platformSpecificData": { "threadItems": [ { "content": "We moved 2 million jobs to a new queue. Retries turned out to be 40% of the load." }, { "content": "Idempotency keys on every job made the cutover boring, which is exactly what you want." } ] } }, { "platform": "bluesky", "accountId": "6710c3e2a9f14b0d8e5c7a2b", "platformSpecificData": { "langs": ["en"], "threadItems": [ { "content": "We moved 2 million jobs to a new queue. What broke:" }, { "content": "Retries were 40% of the load. Idempotency keys made the cutover boring." } ] } }, { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21", "customContent": "We moved 2 million background jobs to a new queue. Two lessons: retries were 40% of the load, and idempotency keys made the cutover boring. Full write-up on the blog." } ] } ``` ## Reply to an existing post on X To answer a post instead of starting a new thread, set `replyToTweetId` to the id of the post you are answering. With `threadItems` as well, the whole thread hangs under that post. X only accepts replies to your own posts or to posts that mention you, and `replyToTweetId` cannot be combined with `replySettings`. To quote a post instead, use `quoteTweetId`. See [X](https://pozy.ai/docs/platforms/x). ## Taking a thread down `POST /v1/posts/{postId}/unpublish` with `"platform": "twitter"`, `"threads"` or `"bluesky"` deletes every post of that thread on that platform. See [Edit and unpublish](https://pozy.ai/docs/publishing/editing). ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/tools/validate/post-length`](https://pozy.ai/docs/api/validatePostLength) | Validate character count | | [`POST /v1/tools/validate/post`](https://pozy.ai/docs/api/validatePost) | Validate post content | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | --- # First comments Post a first comment automatically right after a post goes live on Instagram, Facebook, LinkedIn, YouTube and Threads. A first comment is a comment Pozy adds to your own post right after it publishes. It is where links go on platforms that hide or penalise them in the caption, and where you put hashtags, credits or a call to action without cluttering the post itself. Set it with `firstComment` in the entry's `platformSpecificData`. ## Add a first comment ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our 2026 pricing report is out: what 400 SaaS companies charge, and why.", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/temp/1730289600_p3r6_report.png" }], "scheduledFor": "2026-11-10T09:00:00", "timezone": "Europe/Berlin", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21", "platformSpecificData": { "firstComment": "Read the full report: https://example.com/pricing-2026" } }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "platformSpecificData": { "firstComment": "#saas #pricing #startups" } } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Our 2026 pricing report is out: what 400 SaaS companies charge, and why.", mediaItems: [{ type: "image", url: "https://pozy.ai/media/temp/1730289600_p3r6_report.png" }], scheduledFor: "2026-11-10T09:00:00", timezone: "Europe/Berlin", platforms: [ { platform: "linkedin", accountId: "6710c3e2a9f14b0d8e5c7a21", platformSpecificData: { firstComment: "Read the full report: https://example.com/pricing-2026" }, }, { platform: "instagram", accountId: "6710c3e2a9f14b0d8e5c7a22", platformSpecificData: { firstComment: "#saas #pricing #startups" }, }, ], }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Our 2026 pricing report is out: what 400 SaaS companies charge, and why.", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/temp/1730289600_p3r6_report.png"}], "scheduledFor": "2026-11-10T09:00:00", "timezone": "Europe/Berlin", "platforms": [ { "platform": "linkedin", "accountId": "6710c3e2a9f14b0d8e5c7a21", "platformSpecificData": {"firstComment": "Read the full report: https://example.com/pricing-2026"}, }, { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "platformSpecificData": {"firstComment": "#saas #pricing #startups"}, }, ], }, timeout=30, ).raise_for_status() ``` The comment is posted from the same account, after the post itself is live. For a scheduled post that is at the scheduled time, not when you create the post. ## Where first comments work | Platform | Supported on | Limit and behaviour | | --- | --- | --- | | Instagram | Feed posts and carousels | Not on Stories. Captions cannot hold clickable links, so this is the usual place for them. | | Facebook | Feed posts and Reels | Not on Stories. Skipped when `facebookSettings.draft` is `true`. | | LinkedIn | Every post type | LinkedIn shows posts with a link in the text to fewer people, so a link in the first comment usually reaches more. Supports the same `@[...]` mentions as the post text. | | YouTube | Videos and Shorts | Up to 10,000 characters. Posted and pinned to the top of the comments. | | Threads | Every post type | Up to 500 characters. Posted as a reply to your post; with `threadItems`, a reply to the first post of the thread. It counts as one more Threads post toward the platform's daily limit. | X, Bluesky and the other platforms have no `firstComment`. On X and Bluesky, add a follow-up post with [threadItems](https://pozy.ai/docs/publishing/threads) instead. ## Things to know - **Drafts skip it.** A first comment is never posted for a draft, including TikTok and Facebook platform drafts. - **It is a real comment.** It is posted by your account and appears with the post's other comments, so you can read and moderate it from the [inbox](https://pozy.ai/docs/inbox/comments). - **Set it before publishing.** A published post cannot be updated, so `firstComment` cannot be added or changed afterwards. From then on the comment is managed like any other comment in the inbox. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | --- # CSV import Create many posts at once from a CSV file with POST /v1/posts/bulk-upload, check the file with a dry run first, or script the import yourself with idempotency keys. When someone hands you a spreadsheet of posts, you have two options. Upload the CSV as it is to `POST /v1/posts/bulk-upload` and let Pozy create one post per row, or read the file yourself and call `POST /v1/posts` per row. The first is one request. The second gives you full control over every field. ## Upload a CSV Each row becomes one post. The smallest useful file: ```csv post_content,platforms,profiles,schedule_time,tz "Monday tip: pin your best post to the top of your profile.","linkedin,twitter",Acme Marketing,2026-11-09 09:00,Europe/Madrid "Wednesday tip: reply to comments within the first hour.","linkedin,twitter",Acme Marketing,2026-11-11 09:00,Europe/Madrid "Friday tip: reuse your top post as a carousel.",instagram,Acme Marketing,2026-11-13 12:00,Europe/Madrid ``` Always run it with `dryRun=true` first. A dry run checks every row and reports problems without creating anything: ```bash cURL curl -X POST "https://api.pozy.ai/v1/posts/bulk-upload?dryRun=true" \ -H "Authorization: Bearer $POZY_API_KEY" \ -F "file=@posts.csv" ``` ```ts Node import { readFile } from "node:fs/promises"; const form = new FormData(); form.append("file", new Blob([await readFile("posts.csv")], { type: "text/csv" }), "posts.csv"); const res = await fetch("https://api.pozy.ai/v1/posts/bulk-upload?dryRun=true", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, body: form, }); console.log(await res.json()); ``` ```python Python with open("posts.csv", "rb") as f: resp = requests.post( "https://api.pozy.ai/v1/posts/bulk-upload", params={"dryRun": "true"}, headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, files={"file": ("posts.csv", f, "text/csv")}, timeout=120, ) print(resp.json()) ``` ```json { "total": 3, "valid": 2, "invalid": 1, "results": [ { "rowIndex": 0, "ok": true, "errors": [] }, { "rowIndex": 1, "ok": true, "errors": [] }, { "rowIndex": 2, "ok": false, "errors": ["Instagram posts need at least one image or video."] } ], "warnings": [], "rateLimitedAccounts": [] } ``` Fix the failing rows, run the dry run again until `invalid` is `0`, then send the same request without `dryRun`. Each created row then carries its `createdPostId`. ## Columns Every row needs a target, a schedule and something to post: | Need | Columns | | --- | --- | | Where | `platforms` (one or more platform ids, comma-separated inside quotes) and `profiles` (the profile the row posts from) | | When | One of: `schedule_time`, a per-platform `schedule_time_`, `publish_now=true`, `use_queue=true` or `is_draft=true` | | What | At least one of `post_content`, `title` or `media_urls` | Optional columns: | Column | What it does | | --- | --- | | `tz` | IANA timezone that `schedule_time` is read in. Without it, the row uses its profile's timezone when every target profile shares one, otherwise UTC. | | `media_urls` | Public URLs of the media to attach. Upload local files first with [Media uploads](https://pozy.ai/docs/publishing/media). | | `custom_content_` | Replaces the text for one platform, for example `custom_content_twitter`. | | `custom_media_` | Replaces the media for one platform, for example `custom_media_instagram`. | | `schedule_time_` | A different time for one platform. | | `hashtags`, `mentions` | Stored on the post but not added to the text. Put them in `post_content` if they should be published. | Some column names from the `POST /v1/posts` body are also accepted, because people often guess them: `content` for `post_content`, `timezone` for `tz`, `scheduledFor` for `schedule_time` and `mediaUrls` for `media_urls`. When a row has both, the CSV name wins unless it is empty. ## Warnings and limits | Situation | Result | | --- | --- | | A column Pozy does not read | Not an error. `warnings` lists it as `unknown_columns:`, so a typo in a header never goes unnoticed. | | `hashtags` or `mentions` filled in | `warnings` includes `not_added_to_caption:`. | | More than 500 rows | Processed, with `rows_exceed_advisory_limit:500` in `warnings`. Large files take longer and are harder to fix. | | More than 5,000 rows | Rejected with `400`. Split the file. | | An account the platform is throttling | Listed in `rateLimitedAccounts` with `rateLimitedUntil`. Move those rows to a later time. | ## Script the import yourself A script gives you per-row control: your own validation, per-row metadata, retries and a log you can resume from. The key is an `Idempotency-Key` that is stable per row, so running the script twice never creates duplicates: ```ts import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; const ACCOUNTS: Record = { linkedin: "6710c3e2a9f14b0d8e5c7a21", twitter: "6710c3e2a9f14b0d8e5c7a23", instagram: "6710c3e2a9f14b0d8e5c7a22", }; // Expects: text,platforms,date,time (no commas inside fields, for brevity) const rows = (await readFile("posts.csv", "utf8")).trim().split("\n").slice(1).map((l) => l.split(",")); for (const [text, platforms, date, time] of rows) { const key = createHash("sha256").update(`${text}|${platforms}|${date}|${time}`).digest("hex"); try { const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": `csv-${key}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: text, scheduledFor: `${date}T${time}:00`, timezone: "Europe/Madrid", platforms: platforms.split("|").map((p) => ({ platform: p, accountId: ACCOUNTS[p] })), metadata: { source: "csv-import", rowKey: key }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log("ok", data.post._id); } catch (err) { console.error("failed", text.slice(0, 40), err); } } ``` Pace the loop to stay inside your [rate limit](https://pozy.ai/docs/getting-started/rate-limits): 30 requests a minute on the free plan and 120 with a payment method. For hundreds of rows, use the bulk endpoint or add a short sleep between calls. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts/bulk-upload`](https://pozy.ai/docs/api/bulkUploadPosts) | Bulk upload from CSV | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | --- # Edit, unpublish and delete Change a post before it publishes, edit published text where the platform allows it, update YouTube video details, take posts down and pin Facebook posts. What you can change depends on whether the post has gone live. Before publishing, almost everything is editable with one `PUT`. After publishing, you are limited to what each platform allows: some let you edit the text, a few let you delete, and some allow neither. | You want to | Before it publishes | After it publishes | | --- | --- | --- | | Change text, media, accounts or time | `PUT /v1/posts/{postId}` | Text only, on some platforms: `POST /v1/posts/{postId}/edit` | | Change a YouTube title, description or thumbnail | `PUT` with new `platformSpecificData` | `POST /v1/posts/{postId}/update-metadata` | | Stop it from going out | `DELETE /v1/posts/{postId}`, or `PUT` with `isDraft: true` | Not applicable | | Remove it from a platform | Not applicable | `POST /v1/posts/{postId}/unpublish` | | Pin it on a Facebook Page | Not applicable | `POST /v1/posts/{postId}/pin` | ## Update a post before it publishes `PUT /v1/posts/{postId}` works on `draft`, `scheduled`, `failed`, `partial` and `cancelled` posts. Send only the fields you want to change: ```bash cURL curl -X PUT https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Webinar moved to Thursday 18:00 CET. Same link, new time.", "scheduledFor": "2026-11-12T17:00:00Z" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Webinar moved to Thursday 18:00 CET. Same link, new time.", scheduledFor: "2026-11-12T17:00:00Z", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status, data.post.scheduledFor); ``` ```python Python resp = requests.put( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Webinar moved to Thursday 18:00 CET. Same link, new time.", "scheduledFor": "2026-11-12T17:00:00Z", }, timeout=30, ) print(resp.json()["post"]["status"]) ``` Rules to keep in mind: - **Full validation runs again.** A non-draft update is checked against every target platform, exactly like a create. If the result breaks a platform rule, you get `400` and nothing changes. - **Send the full `platforms` list.** When you include `platforms`, list every entry the post should end up with, not only the one you changed. - **Settings objects are replaced whole.** A `platformSpecificData`, `tiktokSettings` or `facebookSettings` you send overwrites the stored one, so include every key you want to keep. - **Drafts need `isDraft: false` to schedule.** See [Drafts and approvals](https://pozy.ai/docs/publishing/drafts-approvals). - **Published posts are read-only,** except for their `recycling` settings. To publish a scheduled post right away, update it with `publishNow: true`. ## Edit text after publishing `POST /v1/posts/{postId}/edit` changes the text of a live post on one platform. Pass `accountId` when the post went to more than one account on that platform; without it, the first matching entry is edited. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/edit \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "linkedin", "content": "Webinar moved to Thursday 18:00 CET (corrected time). Same link." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/edit", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "linkedin", content: "Webinar moved to Thursday 18:00 CET (corrected time). Same link." }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.success, data.url); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/edit", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"platform": "linkedin", "content": "Webinar moved to Thursday 18:00 CET (corrected time). Same link."}, timeout=30, ) print(resp.json()) ``` | Platform | Text edits | Notes | | --- | --- | --- | | X (`twitter`) | Yes, with limits | The account needs a paid X subscription. Within 1 hour of publishing, up to 5 times, single posts only (not threads). X gives the edited post a new id, returned as `id`. | | Facebook | Yes | | | LinkedIn | Yes | | | YouTube | Yes | Edits the video description. For titles and other details use update-metadata (below). | | Pinterest | Yes, where Pinterest allows it | Pinterest restricts pin edits to some accounts. | | Google Business Profile | Yes | | | Telegram, Discord, Slack | Yes | Edits the message in place. | | Reddit | Yes | Publishing to Reddit is paused, see [Reddit](https://pozy.ai/docs/platforms/reddit). | | Instagram, Threads, TikTok, Snapchat, Bluesky, WhatsApp | No | These platforms have no edit API. Unpublish and post again where possible. | Media cannot be changed after publishing on any platform. ## Update a YouTube video's details `POST /v1/posts/{postId}/update-metadata` changes a published YouTube video without uploading it again. Send `platform: "youtube"` and at least one of: `title` (100 characters), `description`, `tags`, `categoryId`, `privacyStatus` (`public`, `private`, `unlisted`), `thumbnailUrl`, `madeForKids`, `containsSyntheticMedia` or `playlistId`. ```json { "platform": "youtube", "title": "Halving our build times (2026 update)", "privacyStatus": "public", "thumbnailUrl": "https://pozy.ai/media/temp/1730289600_y9u4_thumb.jpg" } ``` It also works for videos uploaded outside Pozy: use `_` as the post id and send the YouTube `videoId` and the channel's `accountId` in the body. The response lists `updatedFields`. ## Take a post down `POST /v1/posts/{postId}/unpublish` deletes the post from one platform. Pozy keeps the record, with its analytics and history. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/unpublish \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/unpublish", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "twitter", accountId: "6710c3e2a9f14b0d8e5c7a23" }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/posts/6721a8c04e9b2f1d7c3a5e88/unpublish", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23"}, timeout=30, ).raise_for_status() ``` | Works on | Does not work on | | --- | --- | | Threads, Facebook, X, LinkedIn, YouTube, Pinterest, Bluesky, Google Business Profile, Telegram, Reddit | Instagram, TikTok, Snapchat | - One call removes one platform. Repeat for each platform you want to clear. - A thread is removed as a whole: every post of the thread on that platform is deleted. - **YouTube deletions are permanent.** The video, its views and its comments are gone. - When every copy is removed the post becomes `cancelled`. Until then it is `partial`. - For Instagram and TikTok, the owner has to delete the post in the app. Pozy notices within about an hour and sends `post.platform.deleted`. ## Delete a post `DELETE /v1/posts/{postId}` removes the post from Pozy. Use it to cancel a draft or a scheduled post before it goes out. - It never touches the platforms. A published post returns `400`; unpublish it first, or keep the record for its analytics. - It sends no webhook. If your app mirrors posts, remove the record on your side when the call succeeds. - A post in `publishing` can only be stopped for entries that have not started uploading yet. ## Pin a Facebook post `POST /v1/posts/{postId}/pin` pins a published post to the top of its Facebook Page, and `POST /v1/posts/{postId}/unpin` removes the pin. Send `platform: "facebook"` and, for posts that went to several Pages, the `accountId`. Other platforms return `400` with `code: "platform_not_supported"`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`PUT /v1/posts/{postId}`](https://pozy.ai/docs/api/updatePost) | Update post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`POST /v1/posts/{postId}/update-metadata`](https://pozy.ai/docs/api/updatePostMetadata) | Update post metadata | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | | [`DELETE /v1/posts/{postId}`](https://pozy.ai/docs/api/deletePost) | Delete post | | [`POST /v1/posts/{postId}/pin`](https://pozy.ai/docs/api/pinPost) | Pin post | | [`POST /v1/posts/{postId}/unpin`](https://pozy.ai/docs/api/unpinPost) | Unpin post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.cancelled`](https://pozy.ai/docs/webhooks/events#post-cancelled) | Post cancelled. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | --- # Validation and dry runs Check text length, media and a full post against every platform's rules before you create it, and ask whether a TikTok account can publish right now. A post that breaks a platform rule fails at publish time, often hours after it was written. The validation endpoints run the same checks up front, with no side effects: nothing is saved, no queue slot is taken and nothing is billed. They do count toward your [rate limit](https://pozy.ai/docs/getting-started/rate-limits), so debounce a live counter. Use them to show problems in your editor while the person is still typing. | Endpoint | Checks | Use it for | | --- | --- | --- | | `POST /v1/tools/validate/post-length` | Character count per platform | A live counter in your editor | | `POST /v1/tools/validate/post` | The full post: text, media, required fields, per-platform rules | A final check before Schedule | | `POST /v1/tools/validate/media` | That a URL is reachable, its type and size, and each platform's size limit | Checking a file someone pasted as a link | | `POST /v1/posts` with `dryRun: true` | Whether each TikTok account can publish right now | Avoiding TikTok daily posting limits | ## Validate a whole post `POST /v1/tools/validate/post` takes the same body as `POST /v1/posts`. Add `accountId` to an X entry so the check uses that account's real text limit: ```bash cURL curl -X POST https://api.pozy.ai/v1/tools/validate/post \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our autumn collection is here. Twelve new pieces, all made in Porto.", "platforms": [ { "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22" }, { "platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23" } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/tools/validate/post", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Our autumn collection is here. Twelve new pieces, all made in Porto.", platforms: [ { platform: "instagram", accountId: "6710c3e2a9f14b0d8e5c7a22" }, { platform: "twitter", accountId: "6710c3e2a9f14b0d8e5c7a23" }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); if (!data.valid) for (const e of data.errors) console.log(`${e.platform}: ${e.error}`); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/tools/validate/post", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Our autumn collection is here. Twelve new pieces, all made in Porto.", "platforms": [ {"platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22"}, {"platform": "twitter", "accountId": "6710c3e2a9f14b0d8e5c7a23"}, ], }, timeout=30, ) result = resp.json() for e in result.get("errors", []): print(e["platform"], e["error"]) ``` ```json { "valid": false, "errors": [ { "platform": "instagram", "error": "Instagram posts need at least one image or video." } ], "warnings": [] } ``` When everything passes, `valid` is `true` and `message` confirms it. `warnings` lists things that will publish but deserve a look, such as text above 90% of a platform's limit. What it does not do: it does not check that the account is connected and healthy, and it does not download or process media. For accounts use [account health](https://pozy.ai/docs/connect/account-health); for files use `validate/media` below. ## Count characters per platform `POST /v1/tools/validate/post-length` takes only `text` and returns a count, limit and verdict per platform: ```json { "text": "Our autumn collection is here. Twelve new pieces, all made in Porto.", "platforms": { "twitter": { "count": 69, "limit": 280, "valid": true }, "twitterPremium": { "count": 69, "limit": 25000, "valid": true }, "bluesky": { "count": 69, "limit": 300, "valid": true }, "instagram": { "count": 69, "limit": 2200, "valid": true } } } ``` X counts differently from the others: every link counts as 23 characters and most emoji count as 2. Two X rows come back because the limit depends on the account. `twitter` is the standard 280. `twitterPremium` applies only to accounts with a paid X subscription, and even those are held to 280 when the post has a poll. Since this endpoint never sees an account, trust `validate/post` with an `accountId` for the final answer. Main text limits: | Platform | Limit | | --- | --- | | X | 280 (more with a paid X subscription) | | Threads | 500 | | Bluesky | 300 | | LinkedIn | 3,000 | | Instagram | 2,200 | | TikTok | 2,200 in the caption | | YouTube | 5,000 in the description, 100 in the title | | Pinterest | 500 in the description, 100 in the title | | Google Business Profile | 1,500 | | Facebook | 63,206 | | Telegram | 4,096 | | Discord | 2,000 | Per-platform pages list other limits, such as hashtag counts and title lengths. ## Check a media URL `POST /v1/tools/validate/media` fetches the headers of a URL and tells you what it found: ```json { "url": "https://example.com/files/autumn-lookbook.mp4" } ``` The response has `valid`, `contentType`, `size` (with a readable `sizeFormatted`), `type` (`image`, `video` or `unknown`), and `platformLimits` comparing the file with each platform's maximum size. A URL that is unreachable, private or returns a web page comes back with `valid: false` and an `error` you can show. Local and private network addresses are always refused. ## Ask TikTok before you post TikTok limits how many posts some accounts can publish in a rolling 24 hours. To check before committing, send the normal create request with `dryRun: true`. Nothing is saved and no queue slot is claimed, so you can repeat it freely: ```json { "dryRun": true, "canPublish": false, "tiktok": [ { "accountId": "6710c3e2a9f14b0d8e5c7a24", "canPublish": false, "alreadyPostedToday": false, "reason": "Daily active user quota reached" } ] } ``` When `canPublish` is `false`, schedule the post a few hours out, or send it as a TikTok draft with `tiktokSettings.draft: true`, which lands in the creator's TikTok inbox and is not limited in the same way. See [TikTok](https://pozy.ai/docs/platforms/tiktok). ## Validate CSV files For a spreadsheet of posts, `POST /v1/posts/bulk-upload?dryRun=true` validates every row at once. See [CSV import](https://pozy.ai/docs/publishing/csv-import). `GET /v1/tools/validate/subreddit` is paused along with Reddit publishing and returns `503` until Reddit reopens its API. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/tools/validate/post-length`](https://pozy.ai/docs/api/validatePostLength) | Validate character count | | [`POST /v1/tools/validate/post`](https://pozy.ai/docs/api/validatePost) | Validate post content | | [`POST /v1/tools/validate/media`](https://pozy.ai/docs/api/validateMedia) | Validate media URL | | [`GET /v1/tools/validate/subreddit`](https://pozy.ai/docs/api/validateSubreddit) | Check subreddit existence | | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/posts/bulk-upload`](https://pozy.ai/docs/api/bulkUploadPosts) | Bulk upload from CSV | --- # External posts See posts your users published directly on a platform, outside Pozy, get webhooks when they appear, change or disappear, and import one on demand. People do not post everything through your app. They post from the Instagram app, schedule in YouTube Studio or update their Google Business Profile in Google's own interface. Pozy picks those posts up too, so your analytics and content lists show the whole account, not only what went through the API. These are **external posts**. ## How Pozy finds them A background sync reads each connected account's recent posts from the platform, roughly every 60 to 90 minutes, and compares them with what it already knows: - A post Pozy has never seen is recorded and `post.external.created` fires. - A post whose text or media changed fires `post.external.updated`. A platform rotating its own media URLs does not count as a change. - A post that disappeared fires `post.external.deleted`, with the detection time in `deletedAt`. Accounts with a listing API are covered: Instagram, Facebook, TikTok, YouTube, X, Threads, Pinterest, Bluesky, Google Business Profile and LinkedIn Pages. LinkedIn personal profiles have no listing API, so their posts only come in when you import them by URL (below). > **The first sync is a backfill:** > > Right after an account is connected, its existing posts are all reported as `post.external.created` once. Treat `created` as an upsert keyed on the platform post id, not as "something new was just posted". ## List external posts `GET /v1/posts` returns posts created through Pozy by default. Pass `source=external` for the others: ```bash cURL curl "https://api.pozy.ai/v1/posts?source=external&accountId=6710c3e2a9f14b0d8e5c7a22&limit=20" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts?source=external&accountId=6710c3e2a9f14b0d8e5c7a22&limit=20", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.posts.length); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"source": "external", "accountId": "6710c3e2a9f14b0d8e5c7a22", "limit": 20}, timeout=30, ) print(len(resp.json()["posts"])) ``` The analytics endpoints take `source` too, with `all` as the default, so reports include external posts unless you ask for `source=pozy`. See [Post analytics](https://pozy.ai/docs/analytics/post-analytics). ## Import one post right now The background sync can take over an hour. When you need a post straight away, for example because a creator just pasted the link to the post they published for your campaign, call `POST /v1/posts/sync-external` with the account and the post's `url` (or its platform `postId`): ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/sync-external \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c3e2a9f14b0d8e5c7a22", "url": "https://www.instagram.com/reel/DBx7kQ2Nf3a/" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/sync-external", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6710c3e2a9f14b0d8e5c7a22", url: "https://www.instagram.com/reel/DBx7kQ2Nf3a/" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.found, data.post?.analytics?.views); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts/sync-external", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"accountId": "6710c3e2a9f14b0d8e5c7a22", "url": "https://www.instagram.com/reel/DBx7kQ2Nf3a/"}, timeout=60, ) result = resp.json() print(result["found"], (result.get("post") or {}).get("analytics")) ``` `found` says whether the post exists on that account, and `post` returns its content, media and current metrics. Pozy reads the account's latest posts live, so this works within seconds of publishing. Calls for the same account within about 15 seconds reuse the last read. | Field | Notes | | --- | --- | | `accountId` | Required. A URL alone cannot be tied to an account, and Pozy reads the platform with that account's access. | | `url` | Any post URL format the platform uses, including short links (`youtu.be`, `vm.tiktok.com`, `pin.it`). | | `postId` | The platform's own id, as an alternative to `url`. | For a LinkedIn personal profile, `url` is required and the post must be authored by the connected member. Imported LinkedIn posts carry metrics but no text or media, because LinkedIn does not share them for personal profiles. An account that needs to be reconnected returns `409`. Reconnect it before retrying. ## What an external post looks like External webhook payloads describe the post in the platform's terms: ```json { "id": "5d2c8a40-7f1e-4c1b-9a3e-0b6f2d1e7c55", "event": "post.external.created", "timestamp": "2026-10-30T11:20:04.000Z", "profiles": [{ "id": "670f9b1d4c2e8a7f3b6d5e10", "externalId": "cust_4821" }], "account": { "id": "6710c3e2a9f14b0d8e5c7a22", "accountId": "6710c3e2a9f14b0d8e5c7a22", "platform": "instagram", "username": "acme.studio" }, "post": { "id": "18045327754123456", "platform": "instagram", "accountId": "6710c3e2a9f14b0d8e5c7a22", "url": "https://www.instagram.com/reel/DBx7kQ2Nf3a/", "content": "Behind the scenes at the Porto workshop.", "mediaType": "VIDEO", "mediaItems": [{ "type": "video", "url": "https://scontent.cdninstagram.com/…/18045327754123456.mp4", "mediaStatus": "available" }], "thumbnailUrl": "https://scontent.cdninstagram.com/…/18045327754123456.jpg", "publishedAt": "2026-10-30T10:58:41.000Z", "source": "external", "deletedAt": null } } ``` - `post.id` is the platform's own post id, not a Pozy post id. There is no Pozy post to update, retry or unpublish. - `mediaItems[].mediaStatus` is `unavailable` when the platform withholds the file, for example for licensed music. - External posts never carry your `metadata`, because Pozy did not create them. - Deletion is detected from the platform's recent-posts listing, so removals of very old posts can go unnoticed. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts | | [`POST /v1/posts/sync-external`](https://pozy.ai/docs/api/syncExternalPosts) | Sync an external post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.external.created`](https://pozy.ai/docs/webhooks/events#post-external-created) | External post created. | | [`post.external.updated`](https://pozy.ai/docs/webhooks/events#post-external-updated) | External post updated. | | [`post.external.deleted`](https://pozy.ai/docs/webhooks/events#post-external-deleted) | External post deleted. | --- # Platforms Every platform Pozy connects to, what each one supports, its API id, and how one post request reaches all of them. Pozy connects to social networks, messaging apps, phone channels, ad networks, stores, blogs, a CRM and the big productivity suites. You connect each account once, and then the same endpoints work across all of them: one post request for every network, one inbox for every conversation, one analytics call for every account. This page is the map. Each platform page covers what that platform supports, how to connect it, the post types and fields it takes, media limits, analytics and inbox details. ## Capability matrix | Platform | Publish | Analytics | DMs | Comments | Reviews | Connect | | --- | --- | --- | --- | --- | --- | --- | | Instagram | Yes | Yes | Yes | Yes | – | OAuth | | TikTok | Yes | Yes | Yes | Yes | – | OAuth | | YouTube | Yes | Yes | – | Yes | – | OAuth | | Facebook | Yes | Yes | Yes | Yes | Yes | OAuth | | LinkedIn | Yes | Yes | – | Yes | – | OAuth | | X | Yes | Yes | Yes | Yes | – | OAuth | | Threads | Yes | Yes | – | Yes | – | OAuth | | Pinterest | Yes | Yes | – | – | – | OAuth | | Bluesky | Yes | Yes | Yes | Yes | – | Credentials | | Google Business Profile | Yes | Yes | – | – | Yes | OAuth | | Snapchat (beta) | Yes | Yes | – | – | – | OAuth | | Reddit (paused) | Yes | Yes | Yes | Yes | – | OAuth | | WhatsApp | Yes | – | Yes | – | – | Credentials | | Telegram (coming soon) | Yes | – | Yes | – | – | Bot token | | Discord (coming soon) | Yes | – | – | – | – | Bot token | | Slack | Yes | – | Yes | – | – | Bot token | | iMessage | – | – | Yes | – | – | Phone number | | SMS | – | – | Yes | – | – | Phone number | | RCS | – | – | Yes | – | – | Phone number | | Shopify | – | – | – | – | – | OAuth | | WooCommerce | – | – | – | – | – | Credentials | | WordPress | – | – | – | – | – | OAuth | | Wix | – | – | – | – | – | OAuth | | Webflow | – | – | – | – | – | API token | | HubSpot | – | – | – | – | – | OAuth | | Google Analytics | – | – | – | – | – | OAuth | | Google Workspace | – | – | – | – | – | OAuth | | Microsoft 365 | – | – | – | – | – | OAuth | Ad networks are listed on [Ads](https://pozy.ai/docs/ads). "Paused" means the platform has switched off third-party access for now. Reddit closed its API to outside apps, so publishing, Reddit DMs and comments are paused; [Reddit Ads](https://pozy.ai/docs/ads/reddit-ads) keep working. ## Platform ids Requests and responses use a short id per platform. Use these ids in `platforms[].platform`, in `?platform=` filters and in connect URLs such as `/v1/connect/{platform}`. | Id | Shown as | Id | Shown as | | --- | --- | --- | --- | | `instagram` | Instagram | `whatsapp` | WhatsApp | | `tiktok` | TikTok | `telegram` | Telegram | | `youtube` | YouTube | `discord` | Discord | | `facebook` | Facebook | `slack` | Slack | | `linkedin` | LinkedIn | `imessage` | iMessage | | `twitter` | **X** | `sms` | SMS | | `threads` | Threads | `rcs` | RCS | | `pinterest` | Pinterest | `shopify` | Shopify | | `bluesky` | Bluesky | `wordpress` | WordPress | | `googlebusiness` | Google Business Profile | `wix` | Wix | | `snapchat` | Snapchat | `webflow` | Webflow | | `reddit` | Reddit | `hubspot` | HubSpot | | `googleanalytics` | Google Analytics | `google` | Google Workspace | | `microsoft` | Microsoft 365 | `woocommerce` | WooCommerce | > **X keeps its old id:** > > X's id is still `twitter`, for compatibility. Show "X" to people and send `twitter` to the API. Ad networks have their own ids (`metaads`, `googleads`, `tiktokads`, `linkedinads`, `pinterestads`, `xads`, `redditads`, `openaiads`) because an ad account is a separate connection from the organic account. See [Ads](https://pozy.ai/docs/ads). ## One request for every network A post lists its targets in `platforms`. Each entry names a connected account, and can override the text, the media, the time and the platform options for that account only. Everything else is shared. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our spring menu is here. Twelve new dishes, all from farms within 50 km. Book a table this week and the dessert is on us.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/spring-menu.jpg" }], "platforms": [ { "platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1" }, { "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4" }, { "platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6", "customContent": "Spring menu is live: 12 new dishes from local farms. Dessert on us this week." }, { "platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "platformSpecificData": { "firstComment": "Reservations: https://example.com/book" } } ], "scheduledFor": "2026-11-03T09:00:00", "timezone": "Europe/Madrid" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Our spring menu is here. Twelve new dishes, all from farms within 50 km. Book a table this week and the dessert is on us.", mediaItems: [{ type: "image", url: "https://cdn.example.com/spring-menu.jpg" }], platforms: [ { platform: "instagram", accountId: "6720c1a94f3b2e0018d4a7f1" }, { platform: "facebook", accountId: "6720c1a94f3b2e0018d4a7f4" }, { platform: "twitter", accountId: "6720c1a94f3b2e0018d4a7f6", customContent: "Spring menu is live: 12 new dishes from local farms. Dessert on us this week." }, { platform: "linkedin", accountId: "6720c1a94f3b2e0018d4a7f5", platformSpecificData: { firstComment: "Reservations: https://example.com/book" } }, ], scheduledFor: "2026-11-03T09:00:00", timezone: "Europe/Madrid", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status); // "scheduled" ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Our spring menu is here. Twelve new dishes, all from farms within 50 km. Book a table this week and the dessert is on us.", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/spring-menu.jpg"}], "platforms": [ {"platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1"}, {"platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4"}, {"platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6", "customContent": "Spring menu is live: 12 new dishes from local farms. Dessert on us this week."}, {"platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "platformSpecificData": {"firstComment": "Reservations: https://example.com/book"}}, ], "scheduledFor": "2026-11-03T09:00:00", "timezone": "Europe/Madrid", }, ) print(resp.json()["post"]["status"]) ``` Per-entry overrides: | Field on a `platforms` entry | Use it for | | --- | --- | | `customContent` | Different text for one account, usually to fit a shorter limit | | `customMedia` | Different media for one account, such as a 9:16 crop for a Story | | `scheduledFor` | A different time for one account | | `platformSpecificData` | Options only that platform understands. Each platform page lists its fields. | Each entry publishes on its own and reports its own `status`, so one network failing does not stop the others. A post where some entries failed ends as `partial`. See [Post lifecycle](https://pozy.ai/docs/publishing/lifecycle). ## Compare text and media limits The limits that most often break a cross-post. Full tables are on each platform page. | Platform | Text limit | Images per post | Videos per post | Text-only posts | | --- | --- | --- | --- | --- | | Instagram | 2,200 | 1, or 10 in a carousel | 1 | No | | TikTok | 2,200 (video), 4,000 (photo description) | Up to 35 | 1 | No | | YouTube | 100 title, 5,000 description | None | 1 | No | | Facebook | 63,206 | 10 | 1 | Yes | | LinkedIn | 3,000 | 20 (or 1 document) | 1 | Yes | | X | 280, or 25,000 with Premium | 4 (or 1 GIF) | 1 | Yes | | Threads | 500 | 10 | 1 | Yes | | Pinterest | 100 title, 800 description | 1 | 1 | No | | Bluesky | 300 | 4 | 1 | Yes | | Google Business Profile | 1,500 | 1 | None | Yes | | Snapchat | 160 (Spotlight) | 1 | 1 | No | Before you schedule a large batch, run it through [validation](https://pozy.ai/docs/publishing/validation) to catch these limits without publishing anything. ## Social networks - [Instagram](https://pozy.ai/docs/platforms/instagram): Feed posts, carousels, Stories and Reels, insights, DMs and comment automations. - [TikTok](https://pozy.ai/docs/platforms/tiktok): Videos, photo carousels and Creator Inbox drafts, with privacy and disclosure settings. - [YouTube](https://pozy.ai/docs/platforms/youtube): Videos and Shorts with thumbnails, playlists, metadata edits and transcripts. - [Facebook](https://pozy.ai/docs/platforms/facebook): Page posts, link carousels, Stories, Reels, Messenger, comments and reviews. - [LinkedIn](https://pozy.ai/docs/platforms/linkedin): Profiles and company pages, documents, polls, mentions and audience targeting. - [X](https://pozy.ai/docs/platforms/x): Posts, threads, replies, quotes, polls, engagement actions and DMs. - [Threads](https://pozy.ai/docs/platforms/threads): Text, media, carousels and thread sequences with topic tags. - [Pinterest](https://pozy.ai/docs/platforms/pinterest): Image and video Pins on boards you choose. - [Bluesky](https://pozy.ai/docs/platforms/bluesky): Posts, threads and DMs with an app password. - [Google Business Profile](https://pozy.ai/docs/platforms/google-business-profile): Updates, offers and events for one or many locations, plus reviews. - [Snapchat](https://pozy.ai/docs/platforms/snapchat): Stories, Saved Stories and Spotlight. - [Reddit](https://pozy.ai/docs/platforms/reddit): Posting is paused while Reddit's API is closed. Reddit Ads still work. ## Messaging - [WhatsApp](https://pozy.ai/docs/platforms/whatsapp): Business messaging with templates, flows, broadcasts and calling. - [Telegram](https://pozy.ai/docs/platforms/telegram): Coming soon. Post to channels and groups and answer chats with a bot. - [Discord](https://pozy.ai/docs/platforms/discord): Coming soon. Messages, embeds, polls and forum posts in your server. - [Slack](https://pozy.ai/docs/platforms/slack): Messages and thread replies in the channels you pick. - [iMessage](https://pozy.ai/docs/platforms/imessage): Blue-bubble conversations from a dedicated sender. - [SMS](https://pozy.ai/docs/platforms/sms): Two-way texting from numbers you buy or port. - [RCS](https://pozy.ai/docs/platforms/rcs): Branded, rich messages on Android with your own agent. ## Commerce, blogs and CRM - [Shopify](https://pozy.ai/docs/platforms/shopify): Products, orders and blog articles from your store. - [WordPress](https://pozy.ai/docs/platforms/wordpress): Publish articles to WordPress.com or a self-hosted site. - [Wix](https://pozy.ai/docs/platforms/wix): Publish blog posts to a Wix site. - [Webflow](https://pozy.ai/docs/platforms/webflow): Publish articles to a Webflow CMS collection. - [HubSpot](https://pozy.ai/docs/platforms/hubspot): Sync inbox contacts and conversations into your CRM. - [Google Analytics](https://pozy.ai/docs/platforms/google-analytics): Run GA4 reports next to your social numbers. ## Email, calendar and files - [Google Workspace](https://pozy.ai/docs/platforms/google): Gmail, Google Calendar, Drive and Contacts. - [Microsoft 365](https://pozy.ai/docs/platforms/microsoft): Outlook mail and calendar, OneDrive and contacts. ## Before you connect anything - Every account belongs to a **profile**. If you are building for your own customers, create one profile per customer first. See [Build a platform](https://pozy.ai/docs/build). - Most platforms connect through the platform's own sign-in screen. A few use credentials, a bot token or a phone number. See [Connecting accounts](https://pozy.ai/docs/connect). - The first 2 connected accounts are free. After that each account is billed per day it stays connected. See [Pricing](https://pozy.ai/docs/billing). --- # Instagram Publish feed posts, carousels, Stories and Reels to Instagram, read account and post insights, and answer DMs and comments. Connect an Instagram professional account and you can publish every format Instagram's API allows: feed images, carousels, Stories and Reels. The same account gives you post and account insights, the DM inbox, comment moderation and comment-to-DM automations. The platform id is `instagram`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Feed images, carousels (up to 10 items), Stories, Reels, trial Reels | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes, every format including Stories | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Analytics | Post metrics, account insights, follower history, demographics, online followers, Story insights | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | DMs | Read and reply, attachments, quick replies, buttons, product cards, reactions | [Messages](https://pozy.ai/docs/inbox/messages) | | Comments | List, reply, hide, delete, private reply | [Comments](https://pozy.ai/docs/inbox/comments) | | Automations | Comment, live comment, Story reply and Story mention triggers | [Comment to DM](https://pozy.ai/docs/automations/comment-to-dm) | | Ads | Through a Meta Ads connection | [Meta Ads](https://pozy.ai/docs/ads/meta-ads) | ## Connect Instagram needs a **Business** or **Creator** account. Personal accounts cannot publish through any third-party API, so ask the user to switch their account type in the Instagram app first (it is free and takes a minute). Start the connection with [`GET /v1/connect/instagram`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`. There are two login methods, chosen with the `loginMethod` query parameter: | `loginMethod` | What the user signs in to | Use it when | | --- | --- | --- | | `instagram_login` (default) | Their Instagram professional account directly. No Facebook Page needed. | You only need publishing, insights and the inbox. | | `facebook_login` | A Facebook Page that has the Instagram account linked. Calls run through that Page. | You also need ads, catalog audio on Reels, the paid partnership label or liking comments. | With `facebook_login` the user picks the Page in a second step after OAuth. Pozy can host that picker for you, or you can build it yourself with [`listInstagramPages`](https://pozy.ai/docs/api/listInstagramPages) and [`selectInstagramAccount`](https://pozy.ai/docs/api/selectInstagramAccount). See [Headless connect](https://pozy.ai/docs/connect/headless). ```bash cURL curl "https://api.pozy.ai/v1/connect/instagram?profileId=6720c0f24f3b2e0018d4a6e0&loginMethod=facebook_login&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/instagram?profileId=6720c0f24f3b2e0018d4a6e0&loginMethod=facebook_login&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Send the user's browser to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/instagram", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6720c0f24f3b2e0018d4a6e0", "loginMethod": "facebook_login", "redirect_url": "https://app.example.com/connected", }, ) print(resp.json()["authUrl"]) ``` > **Ask for less:** > > Pass `scopes` to request only the permission areas you need (for example publishing and analytics, without ads). Fewer permissions on the consent screen means fewer users who stop halfway. See [Scopes](https://pozy.ai/docs/connect/scopes). ## Post types Instagram decides the format from the media you send, with one field to override it. Every post needs media: there are no text-only Instagram posts. | You send | Instagram publishes | Extra fields | | --- | --- | --- | | One image | Feed post | `altText`, `userTags`, `collaborators`, `locationId`, `firstComment` | | 2 to 10 images or videos | Carousel | Same as feed. Every item takes the aspect ratio of the first one. | | One video | Reel | `shareToFeed`, `instagramThumbnail` or `thumbOffset`, `audioName`, `audioConfiguration`, `trialParams` | | Any single image or video with `contentType: "story"` | Story | `userTags` (coordinates optional) | ### Feed post ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "First light over the harbour. #morning", "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/harbour.jpg", "altText": "Fishing boats at dawn" } ], "platforms": [ { "platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1" } ], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "First light over the harbour. #morning", mediaItems: [ { type: "image", url: "https://cdn.example.com/harbour.jpg", altText: "Fishing boats at dawn" }, ], platforms: [{ platform: "instagram", accountId: "6720c1a94f3b2e0018d4a7f1" }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "First light over the harbour. #morning", "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/harbour.jpg", "altText": "Fishing boats at dawn"} ], "platforms": [{"platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1"}], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` `altText` applies to feed images and carousel images. Instagram cuts it at 1,000 characters and ignores it on Reels, Stories and video slides. ### Carousel Put up to 10 images and videos in `mediaItems`. Mixed media is fine. Crop everything to the same ratio before you upload, because Instagram uses the first item's ratio for all of them. ```json "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/menu-1.jpg" }, { "type": "image", "url": "https://cdn.example.com/menu-2.jpg" }, { "type": "video", "url": "https://cdn.example.com/kitchen.mp4" } ] ``` ### Reel A single video is always a Reel. Set `shareToFeed: false` to keep it in the Reels tab only, and give it a cover with `instagramThumbnail` (a JPEG or PNG URL, 1080 x 1920 px works best) or a frame offset in milliseconds with `thumbOffset`. ```json { "platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1", "platformSpecificData": { "shareToFeed": true, "instagramThumbnail": "https://cdn.example.com/reel-cover.jpg", "audioName": "Studio session" } } ``` **Trial Reels** are shown to non-followers first. Add `trialParams.graduationStrategy`: `MANUAL` (you promote it yourself in the app) or `SS_PERFORMANCE` (Instagram shares it with followers if it performs well). **Catalog audio.** `audioConfiguration` attaches a track from Instagram's audio library. Find an `audioId` with [Search Instagram audio](https://pozy.ai/docs/api/searchInstagramAudio) (leave out `q` to get what is trending), then send it with optional `audioVolume` and `videoVolume` from 0 to 100. This needs `facebook_login`. If the track is pulled before a scheduled Reel goes out, the post fails with a message naming the cause rather than publishing with different sound. [Get Instagram audio metadata](https://pozy.ai/docs/api/getInstagramAudio) re-checks a stored id before publish time. `muteAudio: true` removes the video's own sound track before upload, so the result is permanently silent. Videos over 200 MB cannot be muted. ### Story Add `contentType: "story"`. Stories show no caption, last 24 hours and cannot carry link stickers through the API. A Story publishes one media item, so give a Story entry its own `customMedia` when the post has several. To publish a feed post and a Story of the same campaign at once, list the account twice: ```json "platforms": [ { "platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1" }, { "platform": "instagram", "accountId": "6720c1a94f3b2e0018d4a7f1", "customMedia": [{ "type": "image", "url": "https://cdn.example.com/story-1080x1920.jpg" }], "platformSpecificData": { "contentType": "story" } } ] ``` Each entry publishes and reports its status separately. ### Platform fields All of these go in `platformSpecificData` on the Instagram entry (schema: `InstagramPlatformData`). | Field | Type | Applies to | Notes | | --- | --- | --- | --- | | `contentType` | `"story"` | Story | Leave out for feed posts and Reels. | | `shareToFeed` | boolean, default `true` | Reels | `false` keeps the Reel off the main grid. | | `collaborators` | array of usernames | Feed, carousel, Reel | Up to 3 public Business or Creator accounts. They get an invite to co-author. | | `userTags` | array of `{ username, x, y, mediaIndex }` | All | `x` and `y` run from 0 to 1 and are required on images. `mediaIndex` picks the carousel slide. | | `firstComment` | string | Feed, carousel, Reel | Posted right after publishing. Captions cannot hold clickable links, so this is where links usually go. | | `locationId` | string | Feed, carousel, Reel | The numeric id of a Facebook Page that has an address. Not a place name. Stories reject it. | | `trialParams` | `{ graduationStrategy }` | Reels | `MANUAL` or `SS_PERFORMANCE`. | | `instagramThumbnail` / `reelCover` | URL | Reels | Custom cover. Wins over `thumbOffset`. | | `thumbOffset` | integer (ms) | Reels | Frame to use as the cover. | | `audioName` | string | Reels | Renames the original audio. Set at creation only. | | `audioConfiguration` | `{ audioId, audioVolume, videoVolume }` | Reels | Catalog track. Needs `facebook_login`. | | `muteAudio` | boolean | Reels, Stories, video slides | Strips the sound before upload. | | `isAiGenerated` | boolean | All | Adds Instagram's AI label for generated images or video. | | `isPaidPartnership` | boolean | Feed, carousel, Reel | Shows the "Paid partnership" label. Needs `facebook_login`. | | `brandedContentSponsors` | array, up to 2 | Feed, carousel, Reel | Usernames or numeric ids of the brands. Turns the label on by itself. The brand is named once they approve the request in Instagram. | | `commentsEnabled` | boolean, default `true` | Feed, carousel, Reel | `false` turns comments off right after publishing. | If a collaborator cannot be invited, the post still publishes and the name comes back in `droppedCollaborators` with a reason. ## Media requirements Pozy compresses images over 8 MB and keeps your original. Media URLs must return the file itself: sharing links from Google Drive, Dropbox, OneDrive or iCloud return a web page and fail. Upload files with [Media uploads](https://pozy.ai/docs/publishing/media) when you do not have a direct URL. **Images** | | Feed and carousel | Story | | --- | --- | --- | | Formats | JPEG, PNG | JPEG, PNG | | Max size | 8 MB each | 8 MB | | Aspect ratio | 4:5 (1080 x 1350) to 1.91:1 (1080 x 566); 1:1 and 9:16 also accepted | 9:16 (1080 x 1920) | | Count | 1, or up to 10 in a carousel | 1 | **Videos** | | Reel | Story | | --- | --- | --- | | Formats | MP4, MOV (H.264) | MP4, MOV (H.264) | | Max size | 300 MB | 100 MB | | Duration | 3 seconds to 15 minutes | 3 to 60 seconds | | Aspect ratio | 9:16, 1080 x 1920 | 9:16, 1080 x 1920 | | Frame rate | 30 fps recommended | 30 fps recommended | ## Limits | Limit | Value | | --- | --- | | Caption | 2,200 characters. About 125 show before "more". | | Posts per account | 100 per rolling 24 hours, all formats combined | | Collaborators | 3 per post | | Branded content sponsors | 2 per post | | Alt text | 1,000 characters | Read how much of the 24-hour allowance is left with [Get Instagram publishing limit](https://pozy.ai/docs/api/getInstagramPublishingLimit). Compare `quotaUsage` with the returned `quotaTotal` instead of hardcoding 100, since Instagram can change the cap. Instagram's API does not support Story stickers (polls, links, countdowns), product tags, filters, Guides or going live. Published Instagram posts cannot be edited or deleted through the API; change them in the Instagram app. ## Analytics [`GET /v1/analytics?platform=instagram`](https://pozy.ai/docs/api/getAnalytics) returns per-post `impressions`, `reach`, `likes`, `comments`, `shares`, `saves` and `views`, plus Reel watch-time fields (`igReelsAvgWatchTime`, `igReelsVideoViewTotalTime`) where Instagram reports them. Account-level endpoints go further: | Endpoint | What you get | | --- | --- | | [Instagram insights](https://pozy.ai/docs/api/getInstagramAccountInsights) | Reach, views, accounts engaged, interactions, follows and unfollows, profile views, link taps. Only `reach` comes as a time series. | | [Follower history](https://pozy.ai/docs/api/getInstagramFollowerHistory) | Daily follower count with `followers_gained` and `followers_lost`, built from daily snapshots. | | [Demographics](https://pozy.ai/docs/api/getInstagramDemographics) | Audience by age, gender, city or country. Needs at least 100 followers. | | [Online followers](https://pozy.ai/docs/api/getInstagramOnlineFollowers) | When followers are online, by hour. Useful with [Best time to post](https://pozy.ai/docs/analytics/best-time). | | [Active Stories](https://pozy.ai/docs/api/listInstagramStories) and [Story insights](https://pozy.ai/docs/api/getInstagramStoryInsights) | Live Stories with views, reach, replies, taps forward and back, exits. | Story numbers are only readable while the Story is live. After 24 hours you get final numbers only if Instagram sent them before expiry, which happens far more reliably on accounts connected with `facebook_login`. If you need final Story metrics, read them before the Story expires. Counts under 5 can come back as 0 because of Instagram's privacy floor. Image and cover URLs in analytics responses are served from Pozy storage and do not expire, so you can cache them. ## Inbox **Direct messages.** List conversations, read messages and reply with [Send message](https://pozy.ai/docs/api/sendInboxMessage). You can send text, images (PNG, JPEG up to 8 MB), video and audio (up to 25 MB), PDFs, up to 13 quick replies, up to 3 buttons (the text must then be 640 characters or less) and product cards of up to 10 elements. Reactions work both ways. > **The 24-hour window:** > > You can message someone freely for 24 hours after their last message. After that, a human-typed reply can still go out for up to 7 days with `messagingType: "MESSAGE_TAG"` and `messageTag: "HUMAN_AGENT"`. Instagram does not allow that tag on automated messages, so workflows and broadcasts refuse it. Instagram does not expose the Message Requests folder separately, so `GET /v1/inbox/conversations?folder=requests` returns `400` with code `PLATFORM_LIMITATION`. Requests still reach you as `message.received` once the person writes, and [Accept a message request](https://pozy.ai/docs/api/acceptConversationRequest) works on them. When another app owns a thread through Instagram's handover protocol, inbound messages arrive with `metadata.standby: true`, automations stay quiet and sends return `409` `not_thread_owner`. Move the thread with [thread control](https://pozy.ai/docs/api/setConversationThreadControl). Conversations and senders can carry an `instagramProfile` object (`isFollower`, `isFollowing`, `followerCount`, `isVerified`). Instagram only reveals the follow relationship for people who have messaged the account, so treat a missing value as unknown, not as "not a follower". When you connect an account, Pozy imports its existing DM history (up to 500 conversations, the newest 500 messages in each). The import runs in the background, arrives already read and fires no webhooks. **Comments.** List comments, post a top-level comment, reply, hide, unhide and delete. [Send private reply](https://pozy.ai/docs/api/sendPrivateReplyToComment) opens a DM from a comment, once per comment, within 7 days. Liking a comment is in limited release on Instagram's side and only works on `facebook_login` accounts; other accounts get `403` `PLATFORM_BETA_RESTRICTED` or `400` `instagram_likes_require_facebook_login`. **Conversation starters.** Set up to 4 ice breakers (80 characters each) and a persistent menu (3 top-level items, 5 nested). A menu postback with payload `pozy:workflow:` starts that workflow. See [Conversation starters](https://pozy.ai/docs/automations/conversation-starters). ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | Cannot process video from this URL | A cloud-drive sharing link instead of a direct file URL | Use a direct URL or upload through [Media](https://pozy.ai/docs/publishing/media). | | Maximum of 100 posts per day reached | The rolling 24-hour cap | Spread posts out. Check [the publishing limit](https://pozy.ai/docs/api/getInstagramPublishingLimit). | | Instagram blocked your request | Instagram's automation detection | Post less often, vary the content, wait before retrying. | | Duplicate content detected | Same caption and media posted recently | Change the caption or media. | | `instagram_audio_requires_facebook_login` | `audioConfiguration` on an Instagram Login account | Reconnect with `loginMethod=facebook_login`. | | `instagram_paid_partnership_requires_facebook_login` | Paid partnership label on an Instagram Login account | Reconnect with `loginMethod=facebook_login`. | | Instagram rejected locationId | The Page id has no address or does not exist | Use the id of a Page with location data. | | Access token expired | The user's session ended or they removed access | Reconnect the account. Listen for `account.disconnected`. | When a post sent with `publishNow: true` is rejected, the response is `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. A `207` is still a 2xx, so check `post.status` rather than only the HTTP status. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/instagram/stories`](https://pozy.ai/docs/api/listInstagramStories) | List active Instagram stories | | [`GET /v1/accounts/{accountId}/instagram/publishing-limit`](https://pozy.ai/docs/api/getInstagramPublishingLimit) | Get Instagram publishing limit | | [`GET /v1/accounts/{accountId}/instagram/audio`](https://pozy.ai/docs/api/searchInstagramAudio) | Search Instagram audio | | [`GET /v1/accounts/{accountId}/instagram/audio/{audioId}`](https://pozy.ai/docs/api/getInstagramAudio) | Get Instagram audio metadata | | [`GET /v1/accounts/{accountId}/instagram/business-discovery`](https://pozy.ai/docs/api/getInstagramBusinessDiscovery) | Look up a public Instagram Business account | | [`GET /v1/accounts/{accountId}/instagram/media/{mediaId}/collaborators`](https://pozy.ai/docs/api/listInstagramMediaCollaborators) | List the collaborators of an Instagram post | | [`GET /v1/accounts/{accountId}/instagram/live-media`](https://pozy.ai/docs/api/listInstagramLiveMedia) | List the live broadcasts of an Instagram account | | [`GET /v1/accounts/{accountId}/instagram/tags`](https://pozy.ai/docs/api/listInstagramTaggedMedia) | List the posts an Instagram account is tagged in | | [`GET /v1/accounts/{accountId}/instagram/mentions/media/{mediaId}`](https://pozy.ai/docs/api/getInstagramMentionedMedia) | Get a post that mentions an Instagram account in its caption | | [`GET /v1/accounts/{accountId}/instagram/mentions/comments/{commentId}`](https://pozy.ai/docs/api/getInstagramMentionedComment) | Get a comment that mentions an Instagram account | | [`GET /v1/accounts/{accountId}/instagram/stories/{storyId}/insights`](https://pozy.ai/docs/api/getInstagramStoryInsights) | Get Instagram story insights | | [`GET /v1/analytics/instagram/account-insights`](https://pozy.ai/docs/api/getInstagramAccountInsights) | Get Instagram insights | | [`GET /v1/analytics/instagram/follower-history`](https://pozy.ai/docs/api/getInstagramFollowerHistory) | Get Instagram follower history | | [`GET /v1/analytics/instagram/demographics`](https://pozy.ai/docs/api/getInstagramDemographics) | Get Instagram demographics | | [`GET /v1/analytics/instagram/online-followers`](https://pozy.ai/docs/api/getInstagramOnlineFollowers) | Get Instagram online followers | | [`GET /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/listInstagramPages) | List Pages with a linked Instagram account | | [`POST /v1/connect/instagram/select-account`](https://pozy.ai/docs/api/selectInstagramAccount) | Select the Page whose Instagram account to connect | | [`GET /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/getInstagramIceBreakers) | Get IG ice breakers | | [`PUT /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/setInstagramIceBreakers) | Set IG ice breakers | | [`DELETE /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/deleteInstagramIceBreakers) | Delete IG ice breakers | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. | | [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # TikTok Publish TikTok videos and photo carousels with the right privacy and disclosure settings, send drafts to the Creator Inbox, and read post and account insights. Publish videos and photo carousels to TikTok, or hand them to the creator's TikTok inbox as drafts to finish in the app. The same account gives you post and account insights, comment management and DM replies. The platform id is `tiktok`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Videos, photo carousels (up to 35 photos), Creator Inbox drafts | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Analytics | Likes, comments, shares, views; watch time, completion, traffic sources and retention on Business connections | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | DMs | Replies only, on eligible Business Accounts | [Messages](https://pozy.ai/docs/inbox/messages) | | Comments | List, post, reply, like, hide, pin, delete your own | [Comments](https://pozy.ai/docs/inbox/comments) | | Automations | Public reply to keyword comments | [Comment to DM](https://pozy.ai/docs/automations/comment-to-dm) | | Ads | Through a TikTok Ads connection, including Spark Ads | [TikTok Ads](https://pozy.ai/docs/ads/tiktok-ads) | ## Connect Start with [`GET /v1/connect/tiktok`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`, then send the user to the returned `authUrl`. TikTok connects through its business login, which works with both personal and business TikTok accounts. The account type comes back on the account as `tiktokAccountType` (`business`, `personal` or `unknown`). A few behaviours worth knowing before you build the flow: - TikTok will not grant a connection without the video permissions, so `scopes` can only trim the analytics, comment and messaging permissions. - If the user leaves out a permission an already connected account needs, nothing changes and the flow returns `missing_tiktok_permissions`. Ask them to connect again and accept every permission. - A profile can hold several TikTok accounts. Connecting an account the profile already has refreshes it. Authorizing a different account into a reconnect replaces the old one and its history, and the match is made on the last known `@handle`, so a renamed handle can look like a new account. > **Older connections:** > > Accounts connected before TikTok's business login was available may still sit on the older connection type. They can publish but get fewer analytics fields, no inbox, and they share a TikTok-wide cap on how many accounts can publish per day. Reconnecting the account moves it to the current connection type. ## Post types TikTok needs media on every post. All TikTok settings go in a `tiktokSettings` object at the top level of the request. It applies to every TikTok entry; to give two TikTok accounts different settings, put the object in each entry's `platformSpecificData.tiktokSettings` instead (the entry wins). Four settings are always required, because TikTok requires an explicit choice for each one: | Field | Required | Notes | | --- | --- | --- | | `privacyLevel` | Yes | One of the values from [creator info](#check-creator-info-first): `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `allowComment` | Yes | Comments on or off. | | `allowDuet`, `allowStitch` | Yes, for videos | Ignored on photo posts. | | `contentPreviewConfirmed`, `expressConsentGiven` | Yes, both `true` | TikTok's legal requirement: the user saw a preview and agreed to post. Collect this consent in your own UI. | Snake case (`privacy_level`, `allow_comment`) is accepted too. ### Video One video, 3 seconds to 10 minutes. Vertical 9:16 is the only format that looks right on TikTok. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Three ways to fold a dumpling #cooking", "mediaItems": [{ "type": "video", "url": "https://cdn.example.com/dumplings.mp4" }], "platforms": [{ "platform": "tiktok", "accountId": "6720c1a94f3b2e0018d4a7f2" }], "tiktokSettings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComment": true, "allowDuet": false, "allowStitch": true, "contentPreviewConfirmed": true, "expressConsentGiven": true }, "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Three ways to fold a dumpling #cooking", mediaItems: [{ type: "video", url: "https://cdn.example.com/dumplings.mp4" }], platforms: [{ platform: "tiktok", accountId: "6720c1a94f3b2e0018d4a7f2" }], tiktokSettings: { privacyLevel: "PUBLIC_TO_EVERYONE", allowComment: true, allowDuet: false, allowStitch: true, contentPreviewConfirmed: true, expressConsentGiven: true, }, publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Three ways to fold a dumpling #cooking", "mediaItems": [{"type": "video", "url": "https://cdn.example.com/dumplings.mp4"}], "platforms": [{"platform": "tiktok", "accountId": "6720c1a94f3b2e0018d4a7f2"}], "tiktokSettings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComment": True, "allowDuet": False, "allowStitch": True, "contentPreviewConfirmed": True, "expressConsentGiven": True, }, "publishNow": True, }, ) print(resp.json()["post"]["status"]) ``` TikTok assigns the video id a few minutes after publishing, so `platformPostUrl` may be `null` in the response. Pozy sends the [`post.tiktok.url_resolved`](https://pozy.ai/docs/webhooks/events#post-tiktok-url-resolved) webhook when the URL is known. **Covers.** `videoCoverTimestampMs` picks a frame (default 1000 ms). `videoCoverImageUrl` uses your own image instead (JPG, PNG or WebP, up to 20 MB) and wins when both are set. > **Video privacy on business connections:** > > Accounts on TikTok's business connection publish videos as public only. Any other `privacyLevel` on a direct video post is rejected. To get a private video, send it as a [draft](#draft-to-the-creator-inbox) and let the creator choose privacy in the app. Photo posts keep every privacy level the creator allows. ### Photo carousel Up to 35 images, no videos in the same post. Set `mediaType: "photo"`. On photo posts `content` becomes the title (90 characters, hashtags and URLs removed), so put the real caption in `description` (up to 4,000 characters). ```json "tiktokSettings": { "mediaType": "photo", "photoCoverIndex": 0, "description": "Our weekend on the coast, start to finish. #travel #roadtrip", "autoAddMusic": true, "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComment": true, "contentPreviewConfirmed": true, "expressConsentGiven": true } ``` ### Draft to the Creator Inbox `draft: true` uploads the media to the creator's TikTok inbox instead of publishing. They get a notification, open TikTok's editor, and set caption, cover, sound and privacy there. Nothing is public until they tap Post. - Pozy marks the entry `published` when TikTok accepts the upload. That is the hand-over, not the moment it goes live. The entry has `platformSpecificData.isDraft: true` and no URL, and `post.tiktok.url_resolved` never fires. - TikTok allows 5 pending drafts per account in any 24 hours. A sixth fails until the creator finishes or discards one in the app. - Location, commercial music and other post details are ignored on drafts, because the creator sets them in TikTok. - The creator needs a recent version of the TikTok app. ### Check creator info first Call [Get TikTok creator info](https://pozy.ai/docs/api/getTikTokCreatorInfo) before you show a TikTok composer. It returns the privacy levels this creator may use, the maximum video length, which interaction toggles they switched off in the app (`enabled: false`), and which commercial content types they can disclose. Pass `mediaType=photo` for photo posts. ### Location, music and disclosures | Field | What it does | Notes | | --- | --- | --- | | `locationId` + `locationName` | Tags a place | Search with [TikTok locations](https://pozy.ai/docs/api/searchTikTokLocations). Video posts on business connections only. | | `musicSoundInfo` | Adds a Commercial Music Library track | `musicSoundId` from [trending commercial music](https://pozy.ai/docs/api/listTikTokCommercialMusic) (prefer the track's `clip.id`), plus `musicSoundVolume`, `musicSoundStart`, `musicSoundEnd`. | | `videoOriginalSoundVolume` | Keeps the video's own sound under the track | 0 to 100. Leave it out and the original sound is muted. | | `autoAddMusic` | Lets TikTok add music | Photo carousels only. | | `commercialContentType` | Discloses commercial content | `none`, `brand_organic` (your own brand) or `brand_content` (paid partnership). Branded content cannot be `SELF_ONLY`. | | `videoMadeWithAi` | AI-generated content label | On business connections, video posts only. | | `isAdsOnly` | Publishes as an ads-only post for Spark Ads | Video posts on business connections. | ### Dry run Send the same request with `dryRun: true` to check whether each TikTok account can publish right now. The response has `canPublish` and one verdict per TikTok entry with a `reason`. Nothing is saved and no allowance is used, so you can call it as often as you like. ## Media requirements A post is one video or up to 35 photos, never both. Sharing links from cloud drives return a web page, not the file, so use direct URLs or [upload the file](https://pozy.ai/docs/publishing/media). | | Photos | Video | | --- | --- | --- | | Formats | JPEG, PNG, WebP | MP4, MOV, WebM (H.264) | | Max size | 20 MB each | 4 GB | | Count | Up to 35 | 1 | | Duration | | 3 seconds to 10 minutes | | Aspect ratio | 9:16 recommended | 9:16 | | Resolution | Scaled down to fit 1080 x 1920 | 1080 x 1920 recommended, 30 fps | ## Limits | Limit | Value | | --- | --- | | Video caption | 2,200 characters | | Photo title / description | 90 / 4,000 characters | | Posts per account (business connection) | 15 videos and 15 photo posts per rolling 24 hours, counted separately | | Pending drafts | 5 per account per 24 hours | | Posts per account per hour | 25 | | DM replies | Up to 10 messages within 48 hours of the user's last message | When an account goes over the 15-a-day allowance, the post is not failed: Pozy holds it and publishes it as soon as the rolling window frees a slot. TikTok also enforces its own daily API limit per account. A post rejected for that fails with "You have created too many posts in the last 24 hours via the API" and can go out once TikTok's window rolls. TikTok moderates API posts more strictly than posts made in the app. The API cannot create duets or stitches, edit or delete a published post, go live, add effects, or start a DM conversation. ## Analytics [`GET /v1/analytics?platform=tiktok`](https://pozy.ai/docs/api/getAnalytics) returns `likes`, `comments`, `shares` and `views` for every post. On business connections you also get `saves`, `reach`, watch time, `completionRate` (0 to 1), `profileViews`, `follows`, `clicks`, `websiteClicks`, `impressionSources` (For You, Follow, Search and others as shares of 1), `audienceTypes`, `audienceCountries` and a per-second `retentionCurve`. TikTok fills these 24 to 48 hours after publishing and only for posts active in the last 7 days, so a fresh post reports zeros at first. The [post timeline](https://pozy.ai/docs/api/getPostTimeline) has the same fields per day. [TikTok account insights](https://pozy.ai/docs/api/getTikTokAccountInsights) adds follower, following, like and video counts, with `followers_gained` and `followers_lost` from daily snapshots. TikTok does not expose account-level impressions or reach on any API. ## Inbox **Comments** work on any connected account on the current connection type. Read a video's comments with [Get post comments](https://pozy.ai/docs/api/getInboxPostComments) using the TikTok video id; each comment includes up to three replies, and passing `commentId` pages through the rest. You can post, reply, like, hide and unhide any comment, delete your own, and [pin](https://pozy.ai/docs/api/pinInboxComment) a top-level comment. Comment webhooks carry the author id only, without a username or picture. **DMs** are reply-only. TikTok lets a business answer a conversation but never start one, and only when: - the account is a TikTok Business Account, - it was registered outside the EEA, Switzerland and the UK, - it accepts messages (from everyone, or by accepting requests). A reply is either text (up to 6,000 characters) or one JPG or PNG image up to 3 MB, never both in one message. After the user's last message TikTok accepts up to 10 messages within 48 hours. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | Selected privacy level is not available for this creator | `privacyLevel` not in the creator's allowed list | Read [creator info](https://pozy.ai/docs/api/getTikTokCreatorInfo) and use one of its values. | | Too many posts in the last 24 hours via the API | TikTok's own daily limit for the account | Wait for the rolling window, or post in the TikTok app. | | TikTok direct posting is at capacity right now | Older connection type sharing TikTok's daily account cap | Retry later, send as a draft, or reconnect the account. | | TikTok flagged this post as potentially risky | TikTok's moderation | Review the content. | | Video upload failed: download failed | TikTok could not fetch the video URL | Use a direct, public file URL. | | Missing required TikTok permissions | The user declined a permission | Reconnect and accept every permission. | | Timeout waiting for platform response | Large videos take TikTok a while to process | Check the post status after a few minutes before retrying. | A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/tiktok/creator-info`](https://pozy.ai/docs/api/getTikTokCreatorInfo) | Get TikTok creator info | | [`GET /v1/accounts/{accountId}/tiktok/locations`](https://pozy.ai/docs/api/searchTikTokLocations) | Search TikTok location tags | | [`GET /v1/accounts/{accountId}/tiktok/commercial-music`](https://pozy.ai/docs/api/listTikTokCommercialMusic) | List trending commercial music | | [`GET /v1/analytics/tiktok/account-insights`](https://pozy.ai/docs/api/getTikTokAccountInsights) | Get TikTok account-level insights | | [`GET /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/getInboxPostComments) | Get post comments | | [`POST /v1/inbox/comments/{postId}/{commentId}/pin`](https://pozy.ai/docs/api/pinInboxComment) | Pin comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/pin`](https://pozy.ai/docs/api/unpinInboxComment) | Unpin comment | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`post.tiktok.url_resolved`](https://pozy.ai/docs/webhooks/events#post-tiktok-url-resolved) | TikTok post URL resolved. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # YouTube Upload videos and Shorts to a YouTube channel with titles, thumbnails, playlists and visibility, edit them after publishing, and read channel analytics and transcripts. Upload long-form videos and Shorts to a YouTube channel, schedule them to go public at an exact time, and change their details afterwards. The same account gives you per-video and channel analytics, comment moderation and the transcript of any of the channel's own videos. The platform id is `youtube`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Videos and Shorts, with title, description, tags, thumbnail, category, playlist | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes. YouTube itself releases the video at the scheduled time. | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit after publishing | Description, title, tags, thumbnail, visibility, playlist. [Unpublish](https://pozy.ai/docs/api/unpublishPost) removes the video. | [Edit published posts](https://pozy.ai/docs/publishing/editing) | | Analytics | Views, likes, comments, shares; daily views, retention, demographics, channel totals | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | Comments | List, reply, delete, moderate (approve, reject, hold, ban author) | [Comments](https://pozy.ai/docs/inbox/comments) | | DMs | No. YouTube has no direct messages. | | | Transcripts | Read the caption track of the channel's own videos | [Transcripts](#transcripts) | | Ads | Through Google Ads | [Google Ads](https://pozy.ai/docs/ads/google-ads) | ## Connect Start with [`GET /v1/connect/youtube`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`. Google shows its account chooser every time, and the channel that connects is the one owned by the identity picked there. There is no separate channel picker. - **Personal channel:** pick your own Google identity. - **Brand Account channel:** pick the Brand Account entry in the chooser. The person connecting needs owner or manager rights on the Brand Account in Google, not only on the channel. - **YouTube Studio access is not enough.** Someone added as an editor or manager inside YouTube Studio cannot connect the channel through the API. A Brand Account owner or manager has to do it. If the chosen identity has no channel, the connection fails with "We couldn't find a YouTube channel for the Google account you authorized". Start again and pick the right entry. A profile holds one YouTube channel. To connect a second channel, create another [profile](https://pozy.ai/docs/build/profiles) and connect it there. > **Suspended channels:** > > If YouTube suspends a channel, every upload fails with `403`. When you post to channels you do not control, check [account health](https://pozy.ai/docs/connect/account-health) before scheduling. ## Post types Every YouTube post is exactly one video. The type is decided by YouTube, not by a flag: | Video | YouTube treats it as | | --- | --- | | 3 minutes or shorter, and vertical (9:16) or square (1:1) | Short | | Longer than 3 minutes, or horizontal | Regular video | The description is the whole of `content`. The title is `platformSpecificData.title`, or the first line of `content` when you leave it out. Tags go in the top-level `tags` array of the request. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We rebuilt our checkout in a weekend. Here is every step.\n\n00:00 Intro\n02:10 The plan", "tags": ["checkout", "case study", "ecommerce"], "mediaItems": [{ "type": "video", "url": "https://cdn.example.com/checkout-rebuild.mp4", "thumbnail": "https://cdn.example.com/checkout-thumb.jpg" }], "platforms": [{ "platform": "youtube", "accountId": "6720c1a94f3b2e0018d4a7f3", "platformSpecificData": { "title": "Rebuilding checkout in one weekend", "visibility": "public", "categoryId": "28", "madeForKids": false } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "We rebuilt our checkout in a weekend. Here is every step.\n\n00:00 Intro\n02:10 The plan", tags: ["checkout", "case study", "ecommerce"], mediaItems: [{ type: "video", url: "https://cdn.example.com/checkout-rebuild.mp4", thumbnail: "https://cdn.example.com/checkout-thumb.jpg", }], platforms: [{ platform: "youtube", accountId: "6720c1a94f3b2e0018d4a7f3", platformSpecificData: { title: "Rebuilding checkout in one weekend", visibility: "public", categoryId: "28", madeForKids: false, }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "We rebuilt our checkout in a weekend. Here is every step.\n\n00:00 Intro\n02:10 The plan", "tags": ["checkout", "case study", "ecommerce"], "mediaItems": [{ "type": "video", "url": "https://cdn.example.com/checkout-rebuild.mp4", "thumbnail": "https://cdn.example.com/checkout-thumb.jpg", }], "platforms": [{ "platform": "youtube", "accountId": "6720c1a94f3b2e0018d4a7f3", "platformSpecificData": { "title": "Rebuilding checkout in one weekend", "visibility": "public", "categoryId": "28", "madeForKids": False, }, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` Before upload, Pozy cleans the tag list: it strips leading `#`, splits comma-joined entries, drops duplicates and skips any tag over 100 characters. It keeps tags in order until the combined length would pass 500 characters. Timestamps in the description (`00:00 Intro`) become chapters on YouTube as usual. End screens, cards and Premieres are not available through the API. ### Platform fields All of these go in `platformSpecificData` on the YouTube entry (schema: `YouTubePlatformData`). | Field | Type | Default | Notes | | --- | --- | --- | --- | | `title` | string, up to 100 characters | First line of `content` | Video title. | | `visibility` | `public`, `private`, `unlisted` | `public` | | | `madeForKids` | boolean | `false` | Marks the video as made for children. This permanently turns off comments, the notification bell, personalised ads, end screens and cards on it. | | `containsSyntheticMedia` | boolean | `false` | Discloses realistic altered or synthetic content. YouTube may label the video. | | `categoryId` | string | `"22"` (People & Blogs) | Common values: `1` Film, `10` Music, `20` Gaming, `22` People & Blogs, `27` Education, `28` Science & Technology. | | `playlistId` | string | | Adds the video to this playlist after upload. | | `firstComment` | string, up to 10,000 characters | | Posted and pinned as the first comment. | | `notifySubscribers` | boolean | `true` | Whether subscribers get notified about the upload. | ### Scheduling When `scheduledFor` is in the future, Pozy uploads the video ahead of time so YouTube can finish processing it. A video meant to be public is uploaded as private with YouTube's own publish time set, and YouTube releases it at that second. Private and unlisted videos are uploaded with their final visibility. The video URL exists once the upload finishes, but viewers cannot open it before release. `firstComment` is posted at the scheduled time, not at upload. Set `timezone` on the request so the time is read in the right zone. See [Scheduling and timezones](https://pozy.ai/docs/publishing/scheduling). ### Playlists List the channel's playlists with [List YouTube playlists](https://pozy.ai/docs/api/getYoutubePlaylists) and create one with [Create YouTube playlist](https://pozy.ai/docs/api/createYoutubePlaylist) (only `title` is required; privacy defaults to private). Then pass the id as `playlistId` when you publish. > **Playlist creation is not idempotent:** > > Repeating a create request, including after a timeout, can create a second playlist. List the playlists before you retry an unclear failure. [Set default YouTube playlist](https://pozy.ai/docs/api/updateYoutubeDefaultPlaylist) stores a default on the account for your own UI to prefill. It is not applied to posts that leave out `playlistId`. ### Edit a published video [Edit published post](https://pozy.ai/docs/api/editPost) with `platform: "youtube"` replaces the description. Pozy strips `<` and `>` and truncates past 5,000 characters, and the call still succeeds when it does. There is no time limit or edit count, and the video keeps its URL. For everything else, use [Update post metadata](https://pozy.ai/docs/api/updatePostMetadata): `title`, `description`, `tags`, `categoryId`, `privacyStatus`, `thumbnailUrl`, `madeForKids`, `containsSyntheticMedia` and `playlistId`. It also works on videos uploaded outside Pozy: use `_` as the post id and pass `videoId` and `accountId`. ```bash curl -X POST https://api.pozy.ai/v1/posts/6721a3b84f3b2e0018d4b902/update-metadata \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "youtube", "title": "Checkout rebuild: the full story", "privacyStatus": "unlisted" }' ``` When the post went to several channels, pass `accountId` to choose which copy to change. Otherwise the first YouTube entry is used. ## Media requirements Very large files (1 GB and up) can take YouTube 30 to 60 minutes or more to process. The video shows as processing meanwhile, so do not upload it again. | | Short | Regular video | | --- | --- | --- | | Formats | MP4, MOV, AVI, WMV, FLV, 3GP, WebM | Same | | Max size | 256 GB | 256 GB | | Duration | Up to 3 minutes | Up to 15 minutes on unverified channels, 12 hours on verified channels | | Aspect ratio | 9:16 or 1:1 | 16:9 | | Recommended | 1080 x 1920, 30 fps, H.264, AAC 128 kbps | 1920 x 1080 or 4K, 24 to 60 fps, H.264 or H.265, AAC 384 kbps | Shorts under 15 seconds loop automatically. **Thumbnails** are set with `thumbnail` on the video item and only work on regular videos, not Shorts. | Property | Requirement | | --- | --- | | Formats | JPEG, PNG, GIF | | Max size | 2 MB (Pozy compresses larger images and rejects them if still too big) | | Recommended | 1280 x 720 (16:9), at least 640 px wide | YouTube only accepts custom thumbnails on phone-verified channels. On an unverified channel the video still publishes and only the thumbnail is skipped. Pozy then stops trying thumbnails on that channel for 7 days, so allow up to a week for them to work after the channel is verified. ## Limits | Limit | Value | | --- | --- | | Title | 100 characters | | Description | 5,000 characters | | Tags | 100 characters each, 500 combined | | Videos per post | 1 | | First comment | 10,000 characters | YouTube gives every app a daily quota and sets upload limits per channel. Liking a video costs 50 units of that quota, and reading a transcript for the first time costs 200, which is why Pozy caches transcripts. The API cannot post Community posts, go live, change monetization settings, dislike videos, upload captions or like comments. ## Analytics [`GET /v1/analytics?platform=youtube`](https://pozy.ai/docs/api/getAnalytics) returns `views`, `likes` and `comments` per video. For more depth: | Endpoint | What you get | | --- | --- | | [YouTube daily views](https://pozy.ai/docs/api/getYouTubeDailyViews) | Per-day views, watch time, subscribers gained and lost, likes, comments and shares for one video. 2 to 3 days behind. | | [Channel insights](https://pozy.ai/docs/api/getYouTubeChannelInsights) | Channel views, watch time, average view duration, subscriber changes. | | [Demographics](https://pozy.ai/docs/api/getYouTubeDemographics) | Age and gender as percentages, country as view counts, for the channel or one video. Signed-in viewers only. | | [Video retention](https://pozy.ai/docs/api/getYouTubeVideoRetention) | The audience retention curve of one video, up to 100 points. | YouTube's API does not expose impressions or thumbnail click-through rate. ## Transcripts [Get a YouTube video transcript](https://pozy.ai/docs/api/getYoutubeCaptions) reads the caption track of one of the channel's own videos, including auto-generated tracks. An uploaded track wins over an auto-generated one in the same language. You get `text` plus timed `cues`, or SubRip with `format=srt`, and `availableTracks` lists the other languages. The first read is fetched from YouTube; later reads come from Pozy's copy (`source: "cache"`). Pass `refresh=true` only when the captions changed. A video the channel does not own, or one with no track in that language, returns `404` (`captions_not_found`). Auto-captions can take a few hours after upload to appear, so treat that `404` as "not yet". ## Inbox YouTube has comments only. List and reply with the [Comments](https://pozy.ai/docs/inbox/comments) endpoints, delete comments, and work the moderation queue with [Set comment moderation status](https://pozy.ai/docs/api/setCommentModeration): `published` approves, `rejected` removes, `heldForReview` sends it back. Add `banAuthor: true` with `rejected` to auto-reject that author from then on. You must own the channel or the video. [Like post](https://pozy.ai/docs/api/likePost) likes a video as any connected channel, and `DELETE` on the same path clears the rating. A video whose owner turned off ratings returns `403`. Comments cannot be liked through the API. New comments are picked up by polling, so a comment automation reply can lag the comment by a few minutes. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | The YouTube account of the authenticated user is suspended (`403`) | YouTube suspended the channel | Check the channel on YouTube and [account health](https://pozy.ai/docs/connect/account-health). | | YouTube upload initialization failed: 403 | Suspension, exhausted upload quota, or a missing permission | Check each of these, then reconnect if permissions are missing. | | YouTube permission error | The user did not grant every permission | Reconnect and accept every permission. | | Failed to fetch video from URL: 404 | The video URL stopped working | Use a stable, public URL or [upload the file](https://pozy.ai/docs/publishing/media). | | We couldn't find a YouTube channel | The chosen Google identity owns no channel | Connect again and pick the identity that owns the channel. | A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/youtube-playlists`](https://pozy.ai/docs/api/getYoutubePlaylists) | List YouTube playlists | | [`POST /v1/accounts/{accountId}/youtube-playlists`](https://pozy.ai/docs/api/createYoutubePlaylist) | Create YouTube playlist | | [`PUT /v1/accounts/{accountId}/youtube-playlists`](https://pozy.ai/docs/api/updateYoutubeDefaultPlaylist) | Set default YouTube playlist | | [`GET /v1/accounts/{accountId}/youtube-captions`](https://pozy.ai/docs/api/getYoutubeCaptions) | Get a YouTube video transcript | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`POST /v1/posts/{postId}/update-metadata`](https://pozy.ai/docs/api/updatePostMetadata) | Update post metadata | | [`GET /v1/analytics/youtube/daily-views`](https://pozy.ai/docs/api/getYouTubeDailyViews) | Get YouTube daily views | | [`GET /v1/analytics/youtube/channel-insights`](https://pozy.ai/docs/api/getYouTubeChannelInsights) | Get YouTube channel insights | | [`GET /v1/analytics/youtube/demographics`](https://pozy.ai/docs/api/getYouTubeDemographics) | Get YouTube demographics | | [`GET /v1/analytics/youtube/video-retention`](https://pozy.ai/docs/api/getYouTubeVideoRetention) | Get YouTube video retention curve | | [`POST /v1/inbox/comments/{postId}/{commentId}/moderation`](https://pozy.ai/docs/api/setCommentModeration) | Set comment moderation status | | [`POST /v1/inbox/posts/{postId}/like`](https://pozy.ai/docs/api/likePost) | Like post | | [`DELETE /v1/inbox/posts/{postId}/like`](https://pozy.ai/docs/api/unlikePost) | Unlike post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # Facebook Publish feed posts, multi-image posts, link carousels, Stories and Reels to Facebook Pages, and run Messenger DMs, comments and Page reviews. Connect a Facebook Page and you can publish text, photos, videos, link carousels, Stories and Reels, save drafts to Facebook's own Publishing Tools, and post to several Pages from one connection. The same account runs Messenger conversations, comments, Page reviews and comment-to-DM automations, and reads Page insights. The platform id is `facebook`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Text, up to 10 images, one video or GIF, link carousels, Stories, Reels, text on a colour background | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit after publishing | Text, or remove the post with [Unpublish](https://pozy.ai/docs/api/unpublishPost) | [Edit published posts](https://pozy.ai/docs/publishing/editing) | | Analytics | Post metrics, Page insights, demographics, reactions by type, post earnings | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | DMs (Messenger) | Read and reply, attachments, quick replies, buttons, cards, reactions, message requests | [Messages](https://pozy.ai/docs/inbox/messages) | | Comments | List, reply, like, hide, delete, private reply | [Comments](https://pozy.ai/docs/inbox/comments) | | Reviews | List and reply to Page recommendations | [Reviews](https://pozy.ai/docs/inbox/reviews) | | Automations | Comment to DM, welcome screen, persistent menu | [Automations](https://pozy.ai/docs/automations) | | Ads | Same connection can run Meta Ads and lead forms | [Meta Ads](https://pozy.ai/docs/ads/meta-ads) | ## Connect Facebook posts as a **Page**. Personal profiles cannot publish through the API, and the person connecting must be an admin or editor of the Page. Start with [`GET /v1/connect/facebook`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`. After Facebook's consent screen the user picks which Page to connect. Pozy hosts that picker by default; to build it into your own UI, use headless mode with [List Facebook pages](https://pozy.ai/docs/api/listFacebookPages) and [Select Facebook page](https://pozy.ai/docs/api/selectFacebookPage). See [Headless connect](https://pozy.ai/docs/connect/headless). The consent screen also asks for ads permissions, so the same connection can drive [Meta Ads](https://pozy.ai/docs/ads/meta-ads) and lead forms. Pass `scopes` to ask for fewer areas. A connection limited to analytics is read-only: it cannot change the Page, and the Page receives no webhooks, so inbox events stop. > **Facebook sessions expire often:** > > Facebook ends sessions more often than most platforms (password changes, security checks, removed Page roles). Subscribe to [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) and send the user a reconnect link when it fires. ### Several Pages on one connection One connection can manage every Page the user administers. List them with [List Facebook pages](https://pozy.ai/docs/api/getFacebookPages) (add `refresh=true` to re-read from Facebook, at most once a minute). Then either: - set `pageId` in `platformSpecificData` on each entry, repeating the same `accountId` once per Page, or - change the default Page with [Update Facebook page](https://pozy.ai/docs/api/updateFacebookPage) so entries without `pageId` go there. ```json "platforms": [ { "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4", "platformSpecificData": { "pageId": "108236514520117" } }, { "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4", "platformSpecificData": { "pageId": "214590882231054" } } ] ``` ## Post types A plain request makes a feed post. Facebook is one of the few platforms that accepts text with no media at all. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Doors open at 9 this Saturday. First 50 visitors get a free tote.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/open-day.jpg" }], "platforms": [{ "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4", "platformSpecificData": { "firstComment": "Directions: https://example.com/visit" } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Doors open at 9 this Saturday. First 50 visitors get a free tote.", mediaItems: [{ type: "image", url: "https://cdn.example.com/open-day.jpg" }], platforms: [{ platform: "facebook", accountId: "6720c1a94f3b2e0018d4a7f4", platformSpecificData: { firstComment: "Directions: https://example.com/visit" }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Doors open at 9 this Saturday. First 50 visitors get a free tote.", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/open-day.jpg"}], "platforms": [{ "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4", "platformSpecificData": {"firstComment": "Directions: https://example.com/visit"}, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` | Post type | How to send it | | --- | --- | | Text | `content` only | | Photo or multi-photo | Up to 10 images in `mediaItems`. Images and videos cannot be mixed. | | Video or GIF | One item. Send a GIF as `type: "video"` and Facebook loops it. | | Link carousel | Images plus `facebookSettings.carouselCards` | | Text on a colour background | `facebookSettings.textFormatPresetId` on a text-only post | | Story | `contentType: "story"` with one image or video | | Reel | `contentType: "reel"` with one vertical video, optional `title` | | Draft in Facebook | `facebookSettings.draft: true` | ### Link carousel Each card is one image with its own link and headline. `carouselCards` must have one entry per image in `mediaItems` (2 to 10, images only). `carouselLink` sets the "See more" card at the end and defaults to the first card's link. Facebook shows about 35 characters of `name` and 30 of `description`; the API accepts up to 255 and truncates on display. ```json { "content": "New arrivals this week", "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/chair.jpg" }, { "type": "image", "url": "https://cdn.example.com/lamp.jpg" } ], "platforms": [{ "platform": "facebook", "accountId": "6720c1a94f3b2e0018d4a7f4" }], "facebookSettings": { "carouselLink": "https://shop.example.com/new", "carouselCards": [ { "link": "https://shop.example.com/chair", "name": "Oak lounge chair", "description": "Ships in 3 days" }, { "link": "https://shop.example.com/lamp", "name": "Brass floor lamp", "description": "Back in stock" } ] }, "publishNow": true } ``` `facebookSettings` can sit at the top level of the request (applies to every Facebook entry) or in an entry's `platformSpecificData` (wins for that entry). ### Stories and Reels - **Story** (`contentType: "story"`): media is required, the caption is not shown, it disappears after 24 hours, and interactive stickers are not available. - **Reel** (`contentType: "reel"`): one 9:16 video of 3 to 60 seconds. `content` is the caption and `title` is a separate Reel title. ### Drafts in Facebook Publishing Tools `facebookSettings.draft: true` saves an unpublished draft in the Page's Publishing Tools, for teams who approve inside Facebook. It works for feed posts and Reels, not Stories. Facebook deletes drafts after about 30 days, and `firstComment` is skipped on drafts. For approvals inside Pozy, see [Drafts and approvals](https://pozy.ai/docs/publishing/drafts-approvals). ### Platform fields Schema: `FacebookPlatformData`, plus `FacebookSettings`. | Field | Notes | | --- | --- | | `contentType` | `story` or `reel`. Leave out for feed posts. | | `title` | Reel title. Reels only. | | `firstComment` | Posted right after publishing. Feed posts and Reels. | | `pageId` | Target Page when the connection manages several. | | `geoRestriction.countries` | Up to 25 uppercase ISO country codes. Only people in those countries can see the post. Feed posts, videos and Reels. | | `facebookSettings.draft` | Save as a Publishing Tools draft. | | `facebookSettings.carouselCards`, `carouselLink` | Link carousel. Cannot be combined with `contentType`. | | `facebookSettings.textFormatPresetId` | Facebook's numeric preset id for a colour background. Text-only feed posts; rejected with `400` when media, cards or `contentType` are present or `content` is empty. Facebook's own composer stops offering backgrounds around 130 characters, so longer text publishes with a warning. | ## Media requirements Pozy converts WebP images to JPEG before upload. Media URLs must be public HTTPS with no login and no redirects; cloud-drive sharing links usually fail. **Images** | | Feed | Story | | --- | --- | --- | | Formats | JPEG, PNG, GIF | JPEG, PNG | | Max size | 4 MB | 4 MB | | Count | Up to 10 | 1 | | Recommended | 1200 x 630 | 1080 x 1920 | **Videos** | | Feed video | Reel | Story | | --- | --- | --- | --- | | Formats | MP4, MOV (H.264) | MP4, MOV | MP4, MOV | | Max size | 4 GB | 4 GB | 4 GB | | Duration | 1 second to 240 minutes | 3 to 60 seconds | 1 to 120 seconds | | Shape | 1280 x 720 minimum | 9:16, required | 1080 x 1920 | **Custom video covers.** Set `thumbnail` on the video item (an image URL up to 10 MB). It works for feed videos and Reels. It is best effort: if Facebook refuses it, the video publishes with Facebook's automatic cover and no error. ## Limits | Limit | Value | | --- | --- | | Post text | 63,206 characters. About 480 show before "See more". | | Images per post | 10 | | Videos per post | 1 | | Geo-restriction | 25 countries | | Carousel cards | 2 to 10 | The API cannot post to personal profiles or Groups, create Events, go live, add Story stickers, or target organic posts by age, gender or interest (country restriction is supported). ## Analytics [`GET /v1/analytics?platform=facebook`](https://pozy.ai/docs/api/getAnalytics) returns `impressions`, `likes`, `comments`, `shares`, `clicks` and `views` per post, plus `engagementRate`. `likes` is the total of all reactions. | Endpoint | What you get | | --- | --- | | [Page insights](https://pozy.ai/docs/api/getFacebookPageInsights) | Page-level media views, engagements, video metrics and followers over up to 89 days (default 30). `followers_gained` and `followers_lost` come from daily snapshots. | | [Page demographics](https://pozy.ai/docs/api/getFacebookDemographics) | Follower breakdowns for the Page. | | [Post reactions](https://pozy.ai/docs/api/getFacebookPostReactions) | A count per reaction type for one post. | | [Post earnings](https://pozy.ai/docs/api/getFacebookPostEarnings) | Lifetime monetization earnings for one post, read live. There is no date range, so never add up totals across dates. Pages without monetization return `0`. | | [Visitor posts](https://pozy.ai/docs/api/listFacebookVisitorPosts) and [tagged posts](https://pozy.ai/docs/api/listFacebookTaggedPosts) | Posts other people made on or about the Page. | Image URLs in analytics responses are served from Pozy storage and do not expire. ## Inbox **Messenger DMs.** Read conversations and reply with [Send message](https://pozy.ai/docs/api/sendInboxMessage): text, images, video, audio, files, up to 13 quick replies, up to 3 buttons (text 640 characters or less) and carousels of up to 10 cards (`imageAspectRatio: "square"` for 1:1 card images). React to messages, archive conversations, and list and accept message requests (`folder=requests`). > **The 24-hour window:** > > Messenger lets you message someone freely for 24 hours after their last message. After that, a reply typed by a person can still go out for up to 7 days with `messagingType: "MESSAGE_TAG"` and `messageTag: "HUMAN_AGENT"`. Automated messages cannot use the tag, so workflows and broadcasts reject it. The other message tags were retired by Facebook in April 2026 and fail. When another app (such as Facebook's Page Inbox) owns a thread, inbound messages arrive with `metadata.standby: true`, Pozy automations stay quiet and sends return `409` `not_thread_owner`. Hand the thread over with [thread control](https://pozy.ai/docs/api/setConversationThreadControl). On connect, Pozy imports the Page's existing Messenger history (up to 500 conversations, newest 500 messages each), in the background, already read, with no webhooks. **Welcome screen and menu.** Set the greeting text (160 characters, per locale), the Get Started button, up to 4 ice breakers per locale and a persistent menu (3 top-level items, 5 nested). Facebook only shows the menu on Pages with a Get Started button. A tap sends a postback that arrives as `message.received` with `metadata.postbackPayload`; a payload of `pozy:workflow:` starts that workflow. See [Conversation starters](https://pozy.ai/docs/automations/conversation-starters). **Comments.** List, reply, like, hide, unhide and delete, and send one private reply per comment within 7 days. Comment-to-DM automations can like or hide the matched comment and send a product card. **Reviews.** List the Page's recommendations and reply to them with the [Reviews](https://pozy.ai/docs/inbox/reviews) endpoints. **Page info.** Read and update the Page's public details (about, description, website, phone, emails, hours) with [Get](https://pozy.ai/docs/api/getFacebookPageInfo) and [Update a Facebook Page's public info](https://pozy.ai/docs/api/updateFacebookPageInfo). Only the fields you send change. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | Photos should be smaller than 4MB and saved as JPG or PNG | Image over 4 MB or unsupported format | Resize to under 4 MB as JPEG or PNG. | | Missing or invalid image file | Facebook could not read the image | Open the URL in a private window; use JPEG or PNG. | | Unable to fetch video file from URL | Facebook could not download the video | Use a direct public URL or [upload the file](https://pozy.ai/docs/publishing/media). | | Facebook tokens expired. Please reconnect. | The session ended | Reconnect the account. | | Confirm your identity before you can publish as this Page | Facebook's security check on the Page | Open the Page in the Facebook app and complete the check. | | `not_thread_owner` (`409`) | Another app owns the conversation | Take or request thread control. | A rejected `publishNow: true` post returns `207` with `post.status: "failed"`; `platforms[].errorMessage`, `errorCategory` and `errorSource` explain why. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/listFacebookPages) | List Facebook pages | | [`POST /v1/connect/facebook/select-page`](https://pozy.ai/docs/api/selectFacebookPage) | Select Facebook page | | [`GET /v1/accounts/{accountId}/facebook-page`](https://pozy.ai/docs/api/getFacebookPages) | List Facebook pages | | [`PUT /v1/accounts/{accountId}/facebook-page`](https://pozy.ai/docs/api/updateFacebookPage) | Update Facebook page | | [`GET /v1/accounts/{accountId}/facebook-page-info`](https://pozy.ai/docs/api/getFacebookPageInfo) | Get a Facebook Page's public info | | [`PATCH /v1/accounts/{accountId}/facebook-page-info`](https://pozy.ai/docs/api/updateFacebookPageInfo) | Update a Facebook Page's public info | | [`GET /v1/analytics/facebook/page-insights`](https://pozy.ai/docs/api/getFacebookPageInsights) | Get Facebook Page insights | | [`GET /v1/analytics/facebook/demographics`](https://pozy.ai/docs/api/getFacebookDemographics) | Get Facebook Page demographics | | [`GET /v1/accounts/{accountId}/facebook-post-reactions`](https://pozy.ai/docs/api/getFacebookPostReactions) | Get Facebook post reactions | | [`GET /v1/analytics/facebook/post-earnings`](https://pozy.ai/docs/api/getFacebookPostEarnings) | Get Facebook post monetization earnings | | [`GET /v1/accounts/{accountId}/facebook-visitor-posts`](https://pozy.ai/docs/api/listFacebookVisitorPosts) | List visitor posts on a Facebook Page | | [`GET /v1/accounts/{accountId}/facebook-tagged-posts`](https://pozy.ai/docs/api/listFacebookTaggedPosts) | List the posts a Facebook Page is tagged in | | [`GET /v1/accounts/{accountId}/messenger-menu`](https://pozy.ai/docs/api/getMessengerMenu) | Get persistent menu | | [`PUT /v1/accounts/{accountId}/messenger-menu`](https://pozy.ai/docs/api/setMessengerMenu) | Set persistent menu | | [`GET /v1/accounts/{accountId}/messenger-greeting`](https://pozy.ai/docs/api/getMessengerGreeting) | Get FB greeting text | | [`PUT /v1/accounts/{accountId}/messenger-greeting`](https://pozy.ai/docs/api/setMessengerGreeting) | Set FB greeting text | | [`GET /v1/accounts/{accountId}/messenger-get-started`](https://pozy.ai/docs/api/getMessengerGetStarted) | Get FB Get Started button | | [`PUT /v1/accounts/{accountId}/messenger-get-started`](https://pozy.ai/docs/api/setMessengerGetStarted) | Set FB Get Started button | | [`GET /v1/accounts/{accountId}/messenger-ice-breakers`](https://pozy.ai/docs/api/getMessengerIceBreakers) | Get FB ice breakers | | [`PUT /v1/accounts/{accountId}/messenger-ice-breakers`](https://pozy.ai/docs/api/setMessengerIceBreakers) | Set FB ice breakers | | [`GET /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/getPageWebhookSubscription) | Read a Facebook Page's webhook subscription | | [`POST /v1/accounts/{accountId}/webhook-subscription`](https://pozy.ai/docs/api/resyncPageWebhookSubscription) | Re-subscribe a Facebook Page to Pozy's webhooks | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. | | [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | | [`lead.received`](https://pozy.ai/docs/webhooks/events#lead-received) | Lead received. | --- # LinkedIn Publish text, images, video, documents, polls and reposts to LinkedIn profiles and company pages, target page audiences, edit posts and read analytics. One LinkedIn connection can post as the member's personal profile or as any company page they administer. You can publish text, images, video, PDF and slide documents, polls and reposts, tag people and companies, target page posts at specific audiences, and edit the text after publishing. The platform id is `linkedin`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Text, up to 20 images, one video, one document, polls, reposts | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit after publishing | Text, no time limit. [Unpublish](https://pozy.ai/docs/api/unpublishPost) removes the post. | [Edit published posts](https://pozy.ai/docs/publishing/editing) | | Mentions | People and companies by profile URL | [Mentions](#mentions) | | Analytics | Post metrics for profiles and pages, page follower and visitor stats, demographics, reactions | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | Comments | Company pages: list, reply, delete. Reactions on comments and posts from either account type. | [Comments](https://pozy.ai/docs/inbox/comments) | | DMs | No. LinkedIn does not open messaging to third-party apps. | | | Ads | Through a LinkedIn Ads connection | [LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads) | ## Connect Start with [`GET /v1/connect/linkedin`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`. After LinkedIn's consent screen, the user chooses whether this account posts as their personal profile or as one of the company pages they administer. Pozy hosts that choice by default; headless mode lets you build it with [List LinkedIn orgs](https://pozy.ai/docs/api/listLinkedInOrganizations) and [Select LinkedIn org](https://pozy.ai/docs/api/selectLinkedInOrganization). To switch an existing account between personal and page posting later, without another login, call [Switch LinkedIn account type](https://pozy.ai/docs/api/updateLinkedInOrganization). The two account types behave differently: | | Personal profile | Company page | | --- | --- | --- | | Video length | Up to 10 minutes | Up to 30 minutes | | Analytics | Posts published through Pozy only, plus saves and sends | Every post, clicks, follower and page-view stats | | Comments API | Reactions only | Read, reply, delete | | Audience targeting and geo-restriction | No | Yes | LinkedIn asks for a broad set of permissions because one connection covers profile posting, pages and ads. Accounts connected before Pozy added the comment and reaction permissions get `403` `linkedin_reconnect_required` on those calls until they reconnect. ## Post types A plain request makes a text post. Media in `mediaItems` makes an image, video or document post. A post carries one media type only. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We cut our onboarding from 14 steps to 4.\n\nThe three things that mattered most are in the comments.", "platforms": [{ "platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "platformSpecificData": { "firstComment": "Full write-up: https://example.com/blog/onboarding" } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "We cut our onboarding from 14 steps to 4.\n\nThe three things that mattered most are in the comments.", platforms: [{ platform: "linkedin", accountId: "6720c1a94f3b2e0018d4a7f5", platformSpecificData: { firstComment: "Full write-up: https://example.com/blog/onboarding" }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "We cut our onboarding from 14 steps to 4.\n\nThe three things that mattered most are in the comments.", "platforms": [{ "platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "platformSpecificData": {"firstComment": "Full write-up: https://example.com/blog/onboarding"}, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` Putting links in `firstComment` rather than the post body is a common LinkedIn habit, because posts with links in the text tend to get less reach. | Post type | How to send it | | --- | --- | | Text | `content`, up to 3,000 characters. A URL with no media gets a preview card unless `disableLinkPreview: true`. | | Images | 1 to 20 images in `mediaItems` | | Video | One video. Optional `thumbnail` on the item (best effort). | | Document | One PDF, PPT, PPTX, DOC or DOCX as `type: "document"`, shown as a swipeable carousel. Set `documentTitle`. | | Poll | `poll` with a question and 2 to 4 options. No media. | | Repost | `reshareUrl` with the post link or URN. No media. | ### Document post LinkedIn requires a title on documents. Pozy uses `documentTitle`, then the media item's `title`, then the filename. The first page is the cover. Password-protected PDFs do not work. ```json "mediaItems": [{ "type": "document", "url": "https://cdn.example.com/2027-benchmarks.pdf" }], "platforms": [{ "platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "platformSpecificData": { "documentTitle": "2027 hiring benchmarks" } }] ``` ### Poll A question of 1 to 140 characters, 2 to 4 options of up to 30 characters each, and a `duration` of `ONE_DAY`, `THREE_DAYS`, `SEVEN_DAYS` (default) or `FOURTEEN_DAYS`. Polls cannot be edited after publishing and cannot be promoted as ads. ```json "platformSpecificData": { "poll": { "question": "How does your team run standups?", "options": ["Daily, live", "Async in chat", "We don't"], "duration": "THREE_DAYS" } } ``` ### Repost `reshareUrl` takes a post link (from "Copy link to post") or a `urn:li:share`, `urn:li:ugcPost` or `urn:li:groupPost` URN. Your `content` becomes the commentary on top; with no text it is a plain reshare. The repost is always authored by your account with the original embedded. LinkedIn's API cannot produce the one-click repost where the original author stays the author. ### Post to several company pages List the pages the member administers with [List LinkedIn orgs](https://pozy.ai/docs/api/getLinkedInOrganizations), then repeat the entry with the same `accountId` and a different `organizationUrn` (`urn:li:organization:` plus the id). Entries without `organizationUrn` go to the account's default. ### Audience targeting and geo-restriction On company pages, `audience` limits who sees the post: by country, region, industry, job function, seniority, company size, interface language, degree, field of study or school. Values inside one facet are combined with OR, and facets are combined with AND. LinkedIn rejects the post if the target audience is under 300 members. Find ids with [Search targeting options](https://pozy.ai/docs/api/searchAdTargeting) using `platform=linkedin`. ```json "platformSpecificData": { "organizationUrn": "urn:li:organization:84512033", "audience": { "countries": ["ES", "DE"], "jobFunctions": ["urn:li:function:8"], "seniorities": ["urn:li:seniority:6"], "staffCountRanges": ["SIZE_51_TO_200", "SIZE_201_TO_500"] } } ``` `geoRestriction.countries` (up to 25 ISO codes) is the simpler form: only followers in those countries see the post. It is merged with `audience.countries`, and codes LinkedIn cannot map are skipped. ### Platform fields Schema: `LinkedInPlatformData`. | Field | Notes | | --- | --- | | `organizationUrn` | Company page to post as. | | `documentTitle` | Title for document posts. | | `firstComment` | Comment posted right after publishing. Mentions in it are posted as plain names. | | `disableLinkPreview` | `true` suppresses the automatic link card. | | `poll` | `{ question, options, duration }`. Not with media or `reshareUrl`. | | `reshareUrl` | Post link or URN to repost. Not with media. | | `audience` | Page audience targeting (see above). | | `geoRestriction` | `{ countries }`, pages only. | ## Mentions Write a mention as a profile or company URL inside `@[...]` in `content` or in the LinkedIn entry's `customContent`: ```text Thanks to @[https://www.linkedin.com/in/maria-lopez] and the team at @[https://www.linkedin.com/company/acme] for the help. ``` Pozy resolves each URL and publishes LinkedIn's own name as the link text. You can choose the visible text with `@[Maria](https://www.linkedin.com/in/maria-lopez)`, but it must be the person's first, last or full name (or the company's exact name), case-sensitive; anything else is replaced and a `warnings` entry comes back. - To resolve ahead of time, call [Resolve LinkedIn mention](https://pozy.ai/docs/api/getLinkedInMentions) and paste the returned `@[Name](urn:li:person:...)` markup, which is published exactly as written. - Looking up a person by URL needs one of your LinkedIn accounts to administer a page. Company lookups do not. - A post can resolve up to 10 mentions (`linkedin_mentions_limit_exceeded`). An unresolvable URL rejects a scheduled or published post with `linkedin_mentions_invalid` and details per mention; drafts save and return warnings instead. - Other platforms in the same post get plain text: the vanity name, or your custom text. ## Edit a published post [Edit published post](https://pozy.ai/docs/api/editPost) with `platform: "linkedin"` replaces the text of a live post on a profile or page. Up to 3,000 characters, no time window, no edit limit. The post keeps its id and LinkedIn shows an "edited" marker. Media, polls and repost targets cannot change. Pass `accountId` when the post went to several LinkedIn accounts. ```bash curl -X POST https://api.pozy.ai/v1/posts/6721a3b84f3b2e0018d4b902/edit \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "linkedin", "accountId": "6720c1a94f3b2e0018d4a7f5", "content": "We cut our onboarding from 14 steps to 4 (updated with Q3 numbers)." }' ``` ## Media requirements Use direct file URLs; sharing links from cloud drives return a web page and fail. **Images:** JPEG, PNG or GIF, up to 8 MB each, up to 20 per post, 552 x 276 to 8192 x 8192 px. Good sizes are 1200 x 627 (landscape), 1080 x 1080 (square) and 1080 x 1350 (portrait). **Video** | Property | Requirement | | --- | --- | | Formats | MP4, MOV, AVI | | Max size | 5 GB | | Duration | 3 seconds to 10 minutes (profile) or 30 minutes (page) | | Resolution | 256 x 144 to 4096 x 2304 | | Aspect ratio | 1:2.4 to 2.4:1 | | Frame rate | 10 to 60 fps | | Recommended | 1920 x 1080, 30 fps, H.264, AAC 192 kbps | **Documents:** PDF, PPT, PPTX, DOC or DOCX, up to 100 MB and 300 pages, one per post. ## Limits | Limit | Value | | --- | --- | | Post text | 3,000 characters | | Images per post | 20 | | Mentions resolved per post | 10 | | Poll question / option | 140 / 30 characters | | Minimum targeted audience | 300 members | LinkedIn rejects duplicate content with `422`, even when the earlier post is weeks old, and a light rephrase is often not enough. The API cannot publish long-form articles, newsletters or events, send InMail, or mix images with a video or document. ## Analytics [`GET /v1/analytics?platform=linkedin`](https://pozy.ai/docs/api/getAnalytics) returns `impressions`, `reach`, `likes`, `comments` and `shares` for every post. `saves` (and sends) exist for personal profiles only, `clicks` for pages only, and `views` for video posts only. For personal profiles LinkedIn only reports posts the member published through Pozy. | Endpoint | What you get | | --- | --- | | [LinkedIn aggregate stats](https://pozy.ai/docs/api/getLinkedInAggregateAnalytics) | Totals or a daily series across a personal profile's posts. | | [LinkedIn org analytics](https://pozy.ai/docs/api/getLinkedInOrgAggregateAnalytics) | Page impressions, clicks, reactions, engagement rate, organic and paid follower gains, page views. The member must be a page administrator. | | [LinkedIn post stats](https://pozy.ai/docs/api/getLinkedInPostAnalytics) | Per-post metrics by URN, for either account type. | | [Post reactions](https://pozy.ai/docs/api/getLinkedInPostReactions) | Individual reactions with reactor profiles. Pages only. | | [Follower demographics](https://pozy.ai/docs/api/getLinkedInDemographics) | Page follower demographics, one `breakdown` per call. | ## Inbox There are no LinkedIn DMs. On company pages you can list comments, reply and delete through [Comments](https://pozy.ai/docs/inbox/comments). [Like comment](https://pozy.ai/docs/api/likeInboxComment) takes the comment URN the comments endpoints return, plus an optional `reactionType` instead of the default like. [Like post](https://pozy.ai/docs/api/likePost) reacts to any post from any connected LinkedIn account, so a founder's profile can react to the company's post. Comments are picked up by polling, so a comment automation reply can lag by a few minutes. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | Content is a duplicate of urn:li:share:... (`422`) | Same or very similar text already posted | Change the text substantially. | | Cannot mix media types | Images with a video or a document | One media type per post. | | Video processing failed | Codec, length or shape out of range | H.264, within the duration limit, ratio between 1:2.4 and 2.4:1. | | `linkedin_reconnect_required` (`403`) | Account connected before reaction permissions existed | Reconnect. | | `linkedin_mentions_invalid` | A mention URL could not be resolved | Check the URLs in `details.mentions`. | | Publishing failed during preflight checks | Posting too fast, or a validation problem | Space posts out and check [account health](https://pozy.ai/docs/connect/account-health). | A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/linkedin-mentions`](https://pozy.ai/docs/api/getLinkedInMentions) | Resolve LinkedIn mention | | [`GET /v1/connect/linkedin/organizations`](https://pozy.ai/docs/api/listLinkedInOrganizations) | List LinkedIn orgs | | [`POST /v1/connect/linkedin/select-organization`](https://pozy.ai/docs/api/selectLinkedInOrganization) | Select LinkedIn org | | [`GET /v1/accounts/{accountId}/linkedin-organizations`](https://pozy.ai/docs/api/getLinkedInOrganizations) | List LinkedIn orgs | | [`PUT /v1/accounts/{accountId}/linkedin-organization`](https://pozy.ai/docs/api/updateLinkedInOrganization) | Switch LinkedIn account type | | [`GET /v1/accounts/{accountId}/linkedin-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInAggregateAnalytics) | Get LinkedIn aggregate stats | | [`GET /v1/analytics/linkedin/org-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInOrgAggregateAnalytics) | Get LinkedIn org analytics | | [`GET /v1/accounts/{accountId}/linkedin-post-analytics`](https://pozy.ai/docs/api/getLinkedInPostAnalytics) | Get LinkedIn post stats | | [`GET /v1/accounts/{accountId}/linkedin-post-reactions`](https://pozy.ai/docs/api/getLinkedInPostReactions) | Get LinkedIn post reactions | | [`GET /v1/analytics/linkedin/demographics`](https://pozy.ai/docs/api/getLinkedInDemographics) | Get LinkedIn organization follower demographics | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`POST /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/likeInboxComment) | Like comment | | [`POST /v1/inbox/posts/{postId}/like`](https://pozy.ai/docs/api/likePost) | Like post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # X Post to X with threads, replies, quotes, polls and media, edit recent posts, retweet, bookmark and follow, send DMs and read post analytics. Publish posts, threads, replies, quotes, polls and long-form Articles to X, edit recent posts, and act as the account (retweet, bookmark, follow, search). The same connection reads post analytics, sends and receives DMs and manages replies to your posts. In the API the platform id is `twitter`; everywhere a person reads it, show **X**. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Text, up to 4 images, 1 GIF or 1 video, threads, replies, quotes, polls, Articles | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit after publishing | Text, within 1 hour, Premium accounts | [Edit](#edit-a-published-post) | | Engagement | Retweet, bookmark, follow, search recent posts, look up a post | [X Engagement endpoints](#endpoints) | | Analytics | Impressions, likes, replies, reposts, bookmarks, clicks, media views | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | DMs | Start conversations, reply, attachments | [Messages](https://pozy.ai/docs/inbox/messages) | | Comments | Replies to your posts: list, reply, like, hide, delete | [Comments](https://pozy.ai/docs/inbox/comments) | | Ads | Through an X Ads connection | [X Ads](https://pozy.ai/docs/ads/x-ads) | ## Connect > **A card on file is required:** > > X charges for every API call, starting with the first one, so Pozy bills X usage per call at the rates on the [pricing page](https://pozy.ai/pricing). Until the workspace has a payment method, [`GET /v1/connect/twitter`](https://pozy.ai/docs/api/getConnectUrl) returns `402` with `type: "payment_required"`, `reason: "twitter_passthrough"` and a `dashboard_url` where the user can add a card. This applies on the free tier too. Rates are on [Pricing](https://pozy.ai/docs/billing). With a card on file, call the connect endpoint with a `profileId` and send the user to `authUrl`. After they approve, they land on your `redirect_url` with `connected=twitter`, `profileId` and `accountId` appended. ```bash cURL curl "https://api.pozy.ai/v1/connect/twitter?profileId=6720c0f24f3b2e0018d4a6e0&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/twitter?profileId=6720c0f24f3b2e0018d4a6e0&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Redirect the user to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/twitter", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6720c0f24f3b2e0018d4a6e0", "redirect_url": "https://app.example.com/connected"}, ) print(resp.json()["authUrl"]) ``` If the user denies consent, they come back with `error=oauth_denied&platform=twitter` on the redirect URL. Treat any other `error` value as a generic failure. ### Spend cap Each workspace can set a monthly cap on X spend in billing settings. Pozy emails a warning at 80% of the cap. At the cap, every billable X call (posts, DMs, reads) is refused before it reaches X with `402` and code `x_spend_cap_reached`, until the cap is raised or the month rolls over. Drafts never count. ## Post types ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Release 2.4 is out: faster sync and a new export screen.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/release-2-4.png" }], "platforms": [{ "platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6" }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Release 2.4 is out: faster sync and a new export screen.", mediaItems: [{ type: "image", url: "https://cdn.example.com/release-2-4.png" }], platforms: [{ platform: "twitter", accountId: "6720c1a94f3b2e0018d4a7f6" }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Release 2.4 is out: faster sync and a new export screen.", "mediaItems": [{"type": "image", "url": "https://cdn.example.com/release-2-4.png"}], "platforms": [{"platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6"}], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` > **Cross-posting long text:** > > A free X account allows 280 characters. When the same post goes to LinkedIn or Facebook too, give the X entry its own `customContent` under the limit, or the X copy fails with "Tweet text is too long" while the others publish. ### Thread Put the whole sequence in `platformSpecificData.threadItems`. Item 0 is the first post and each item replies to the one before. Each item can have its own `mediaItems`. When `threadItems` is set, the top-level `content` is kept for search and display in Pozy but is not published, so the first post must be `threadItems[0]`. ```json { "platform": "twitter", "accountId": "6720c1a94f3b2e0018d4a7f6", "platformSpecificData": { "threadItems": [ { "content": "How we halved our cloud bill in 6 weeks. A thread.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/bill-chart.png" }] }, { "content": "1. We turned off every environment nobody had logged into for 30 days." }, { "content": "2. We moved batch jobs to spot instances." }, { "content": "3. We set budgets per team, with alerts. That one changed behaviour fastest." } ] } } ``` A thread cannot carry a poll. ### Reply and quote - **Reply:** `replyToTweetId` with the id of the post. X only accepts replies to your own posts or posts that mention you. Combine it with `threadItems` to reply with a whole thread; only the first item replies to the target. It cannot be combined with `replySettings`. - **Quote:** `quoteTweetId` with the post id or its full URL. X only accepts quotes of your own posts, posts that mention you, or posts in a conversation you are part of. A quote cannot carry media or a poll. A quote made this way is billed at the normal post rate, while pasting the post's URL into `content` is billed as a post with a link, which costs much more. To find ids, use [Search recent tweets](https://pozy.ai/docs/api/searchTweets) (the last 7 days) or resolve a URL with [Look up a tweet](https://pozy.ai/docs/api/getTweet). ### Poll `poll` takes 2 to 4 `options` of 1 to 25 characters and `duration_minutes` from 5 to 10,080 (7 days). It cannot be combined with media, `threadItems` or `quoteTweetId`. ```json "platformSpecificData": { "poll": { "options": ["Dark mode", "Offline sync", "More exports"], "duration_minutes": 1440 } } ``` ### Article `article` publishes a long-form X Article on an eligible Premium+ account. It needs a `title` and a `content_state` in X's own block format (`blocks` with types such as `unstyled`, `header-one`, `ordered-list-item`, plus `entities` for links and images). `mode: "draft"` stops after creating the draft on X; `publish` (default) publishes it and makes two billable X calls. An optional `cover.url` takes a public JPG, PNG or WebP. Articles cannot be combined with any other post option. ### Platform fields Schema: `TwitterPlatformData`. | Field | Notes | | --- | --- | | `threadItems` | The full thread, first post first. | | `replyToTweetId` | Post to reply to. | | `quoteTweetId` | Post id or URL to quote. | | `replySettings` | Who can reply: `following`, `mentionedUsers`, `subscribers` or `verified`. Everyone when left out. First post only in a thread. | | `poll` | `{ options, duration_minutes }`. | | `mediaTaggedUsers` | Up to 10 handles to tag in the attached media. | | `geoRestriction.countries` | Hides the media (not the text) outside up to 25 countries. Ignored without media. | | `sensitiveMedia` | `{ adultContent, graphicViolence, other }`, at least one `true`. Adds a warning to every attached item. | | `paidPartnership` | Paid promotion label. First post only. Depends on X's access tier. | | `madeWithAi` | AI-generated media label (images and video, not text). First post only. | | `longVideo` | Uses X's long-video upload path. Applied only on accounts with a paid X subscription and ignored otherwise; some accounts also need allowlisting by X. | | `article` | Long-form Article (see above). | ## Edit a published post [Edit published post](https://pozy.ai/docs/api/editPost) with `platform: "twitter"` changes the text, but X only allows it on accounts with X Premium, within 1 hour of publishing, up to 5 times, on single posts (not threads), and for text only. X creates a new post id on every edit and Pozy returns it as `id`. Outside those rules the call returns `400`. [Unpublish](https://pozy.ai/docs/api/unpublishPost) deletes a published post from X. ## Media requirements A post carries up to 4 images, or 1 GIF, or 1 video. Use direct file URLs or [upload the file](https://pozy.ai/docs/publishing/media). | | Images | GIF | Video | | --- | --- | --- | --- | | Formats | JPEG, PNG, WebP, GIF | GIF | MP4, MOV | | Max size | 5 MB | 15 MB | 512 MB | | Count | Up to 4 | 1 (takes all slots) | 1 | | Dimensions | 4 x 4 to 8192 x 8192 | Up to 1280 x 1080 | 32 x 32 to 1920 x 1200 | | Duration | | | From 0.5 seconds; the maximum is set by X per account | | Other | 1200 x 675 (16:9) recommended | Auto-plays | Up to 40 fps and 25 Mbps; 1280 x 720, 30 fps, H.264, AAC recommended | Send a GIF as `type: "gif"`. ## Limits | Limit | Value | | --- | --- | | Post text | 280 characters (free), 25,000 (Premium). URLs count as 23, emojis as 2. | | Posts per account per hour | 25. Extra posts are retried when the hour resets. | | DMs | 200 requests per 15 minutes and 1,000 per 24 hours per account | | Poll options | 2 to 4, 25 characters each | | Edits | 5 within 1 hour, Premium only | If X rate-limits an account, Pozy pauses publishing on it for 10 minutes, doubling on each further hit within 24 hours. Posts scheduled inside the pause fail with the same message. X rejects text it already has from the account, so repeated posts need changes. The API cannot use Spaces, post to Communities, pin a post to the profile, create cards (use meta tags on the linked page) or send broadcast DMs. ## Analytics [`GET /v1/analytics?platform=twitter`](https://pozy.ai/docs/api/getAnalytics) returns `impressions`, `likes`, `comments`, `shares`, `saves` (bookmarks), `clicks` and `views` per post. `views` is X's media view count, so text and image posts report `0`. The date range defaults to the last 90 days and can be up to 366 days. Every analytics read is an X call billed at X's rate. ## Engagement Act as a connected account with the [X Engagement](#endpoints) endpoints. Each `POST` has a `DELETE` on the same path to undo it. | Action | Call | Body | | --- | --- | --- | | Retweet | `POST /v1/twitter/retweet` | `accountId`, `tweetId` | | Bookmark | `POST /v1/twitter/bookmark` | `accountId`, `tweetId` | | Follow | `POST /v1/twitter/follow` | `accountId`, `targetUserId`. Protected accounts return `pending_follow: true`. | A `403` means X refused the action for this account, usually a missing permission or a suspended account. Reconnect and accept every permission. ## Inbox **DMs.** Start a conversation with [Create conversation](https://pozy.ai/docs/api/createInboxConversation), passing `accountId`, the recipient as `participantUsername` or `participantId`, and `message`. An existing conversation with that person is reused. Pozy first checks that the person accepts DMs from you and returns `422` `DM_NOT_ALLOWED` if not; pass `skipDmCheck: true` when you have already checked. ```bash curl -X POST https://api.pozy.ai/v1/inbox/conversations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6720c1a94f3b2e0018d4a7f6", "participantUsername": "acme_support", "message": "Thanks for flagging this. Which screen showed the error?" }' ``` Later messages go through [Send message](https://pozy.ai/docs/api/sendInboxMessage) and can include images or video up to 25 MB. Conversation lists are cached for 2 minutes. X's newer encrypted chat is not visible to any third-party app, so some conversations may look empty or show only your side. X DMs are billed at X's rate, not on Pozy's outbound message meter. **Comments** are the replies to your posts. Reply with [Reply to comment](https://pozy.ai/docs/api/replyToInboxPost), adding `commentId` to answer a specific reply. `postId` can be the Pozy post id or the X post id, so posts made outside Pozy work too. You can also like, hide and delete replies. Send an `Idempotency-Key` when you retry, so a retry never posts twice. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | `402` `payment_required`, `reason: "twitter_passthrough"` | No card on file | Add a payment method at `dashboard_url`, then connect again. | | `402` `x_spend_cap_reached` | The workspace's monthly X cap is used up | Raise or remove the cap in billing. | | Tweet text is too long | Over 280 characters on a free account | Shorten the text or set `customContent`. | | X does not allow duplicate tweets | Same text posted before | Change the text. | | Rate limit hit. Please wait 10 minutes | X rate-limited the account | Wait for the pause to end. | | Hourly limit reached (25/25) | Pozy's per-account hourly limit | Nothing; the post is retried when the hour resets. | | `422` `DM_NOT_ALLOWED` | The recipient does not accept your DMs | They need to open DMs to you first. | | `401` on later calls | X revoked the session | Reconnect the account. | A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. A failed X post is not retried automatically. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/twitter/retweet`](https://pozy.ai/docs/api/retweetPost) | Retweet a post | | [`DELETE /v1/twitter/retweet`](https://pozy.ai/docs/api/undoRetweet) | Undo retweet | | [`POST /v1/twitter/bookmark`](https://pozy.ai/docs/api/bookmarkPost) | Bookmark a tweet | | [`DELETE /v1/twitter/bookmark`](https://pozy.ai/docs/api/removeBookmark) | Remove bookmark | | [`POST /v1/twitter/follow`](https://pozy.ai/docs/api/followUser) | Follow a user | | [`DELETE /v1/twitter/follow`](https://pozy.ai/docs/api/unfollowUser) | Unfollow a user | | [`GET /v1/twitter/search`](https://pozy.ai/docs/api/searchTweets) | Search recent tweets | | [`GET /v1/twitter/tweet`](https://pozy.ai/docs/api/getTweet) | Look up a tweet | | [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | | [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # Threads Publish text, image, video and carousel posts and multi-post threads to Threads, set topic tags, manage replies and read post metrics. Publish text posts, images, videos, carousels and chains of connected posts to Threads, with a topic tag and a first reply. The same account reads post metrics and manages the replies people leave. The platform id is `threads`. ## What you can do | Capability | Supported | Where | | --- | --- | --- | | Publish | Text, one image, one video, carousels of up to 10 images, thread sequences | [Create a post](https://pozy.ai/docs/publishing) | | Schedule | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Delete after publishing | Yes, with [Unpublish](https://pozy.ai/docs/api/unpublishPost) | [Edit and unpublish](https://pozy.ai/docs/publishing/editing) | | Analytics | Views, likes, replies, reposts and quotes | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | Replies | List, reply, hide, unhide, delete | [Comments](https://pozy.ai/docs/inbox/comments) | | DMs | No. Threads has no messaging API. | | | Automations | Public reply to keyword replies | [Comment to DM](https://pozy.ai/docs/automations/comment-to-dm) | ## Connect Threads profiles belong to Instagram accounts, so the user signs in with the Instagram **Business or Creator** account that owns the Threads profile. Start with [`GET /v1/connect/threads`](https://pozy.ai/docs/api/getConnectUrl) and a `profileId`, then send the user to `authUrl`. Because Threads depends on Instagram, an Instagram account that is restricted or loses access also stops publishing to Threads. If a Threads post fails with "Instagram account is restricted", fix the Instagram account first. ## Post types Threads accepts text with no media, which makes it one of the easiest places to cross-post short updates. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Small teams ship faster when the default answer to a meeting is no.", "platforms": [{ "platform": "threads", "accountId": "6720c1a94f3b2e0018d4a7f7", "platformSpecificData": { "topic_tag": "Startups" } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Small teams ship faster when the default answer to a meeting is no.", platforms: [{ platform: "threads", accountId: "6720c1a94f3b2e0018d4a7f7", platformSpecificData: { topic_tag: "Startups" }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Small teams ship faster when the default answer to a meeting is no.", "platforms": [{ "platform": "threads", "accountId": "6720c1a94f3b2e0018d4a7f7", "platformSpecificData": {"topic_tag": "Startups"}, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` | Post type | How to send it | | --- | --- | | Text | `content`, up to 500 characters | | Image | One image in `mediaItems` | | Video | One video, up to 5 minutes | | Carousel | 2 to 10 images | | Thread sequence | `platformSpecificData.threadItems` | > **500 characters:** > > Threads posts stop at 500 characters. A caption cross-posted from LinkedIn, Facebook or Instagram usually runs longer and fails with "Param text must be at most 500 characters long". Give the Threads entry its own `customContent`. ### Thread sequence `threadItems` publishes a chain: item 0 is the first post and each later item replies to the one before. Every item can carry text, media or both (text is optional when there is media). When `threadItems` is set, the top-level `content` is stored in Pozy for display and search but not published. ```json { "platform": "threads", "accountId": "6720c1a94f3b2e0018d4a7f7", "platformSpecificData": { "threadItems": [ { "content": "What we learned shipping a mobile app with two engineers.", "mediaItems": [{ "type": "image", "url": "https://cdn.example.com/app-cover.jpg" }] }, { "content": "1. One codebase. We never regretted it." }, { "content": "2. A weekly release train, even when the train was half empty." }, { "content": "3. Support tickets in the team channel, not a separate tool." } ] } } ``` ### Platform fields Schema: `ThreadsPlatformData`. | Field | Notes | | --- | --- | | `topic_tag` | 1 to 50 characters, no periods or ampersands. Overrides the topic Threads would take from hashtags in the text. | | `threadItems` | The whole sequence, first post first. Each item: `content`, `mediaItems`. | | `firstComment` | A reply to your own post right after publishing (to the first post when using `threadItems`). Up to 500 characters. It is a post in its own right, so it counts toward the daily limit. | ## Media requirements Threads fetches media the same way Instagram does. Media URLs must return the file itself (cloud-drive sharing links fail), and WebP images can fail, so send JPEG or PNG. Images over 8 MB are compressed automatically. | | Images | Video | | --- | --- | --- | | Formats | JPEG, PNG | MP4 (H.264, AAC) | | Max size | 8 MB each | 1 GB | | Count | 1, or up to 10 in a carousel | 1 | | Duration | | Up to 5 minutes | | Aspect ratio | 4:5 (1080 x 1350) recommended; 1:1 and 16:9 work | 9:16, 16:9 or 1:1 | | Other | | 1080p, 30 fps, AAC 128 kbps recommended | ## Limits | Limit | Value | | --- | --- | | Post text | 500 characters | | First reply | 500 characters | | Topic tag | 50 characters | | Posts per account | 250 per 24 hours, including first replies and each post in a sequence | | Carousel | 10 images | The API cannot create polls or quote posts, search GIFs, edit a published post, show who liked or reposted, or like replies. ## Analytics [`GET /v1/analytics?platform=threads`](https://pozy.ai/docs/api/getAnalytics) returns `views`, `likes`, `comments` (replies) and `shares` per post. Threads has no impressions metric, so use `views` as the reach figure. `shares` adds reposts and quotes together. ## Inbox Threads has no DMs. Replies to your posts show up as comments. A comment on Threads is itself a reply to the post, so calling [Reply to comment](https://pozy.ai/docs/api/replyToInboxPost) without a `commentId` replies to the post itself, and with one it answers that reply. You can delete replies, and [hide](https://pozy.ai/docs/api/hideInboxComment) or unhide them. Liking replies is not supported. New replies are picked up by polling, so automated replies can lag by a few minutes. ## Errors you may see | Message | Cause | Fix | | --- | --- | --- | | Param text must be at most 500 characters long | Text over the limit | Shorten it or set `customContent` for Threads. | | Media download has failed. The media URI doesn't meet our requirements. | Threads could not fetch the file | Use a direct file URL; switch WebP to JPEG or PNG. | | Instagram account is restricted | The linked Instagram account has a restriction | Resolve it in Instagram, then retry. | | Publishing failed due to max retries reached | Temporary failure on Threads' side | Wait a few minutes and retry. | A rejected `publishNow: true` post returns `207` with `post.status: "failed"` and the reason in `platforms[].errorMessage`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/posts/{postId}/unpublish`](https://pozy.ai/docs/api/unpublishPost) | Unpublish post | | [`GET /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/getInboxPostComments) | Get post comments | | [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment | | [`POST /v1/inbox/comments/{postId}/{commentId}/hide`](https://pozy.ai/docs/api/hideInboxComment) | Hide comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/hide`](https://pozy.ai/docs/api/unhideInboxComment) | Unhide comment | | [`DELETE /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/deleteInboxComment) | Delete comment | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # Pinterest Publish image and video pins to Pinterest boards, create boards, edit pin descriptions and read impressions, saves and clicks. Pinterest works like a visual search engine. A pin keeps getting found for months, so the title, description, board and destination link matter more than the time you post. With Pozy you publish image and video pins with the same `POST /v1/posts` call you use everywhere else, using the platform id `pinterest`. ## What you can do | Feature | Supported | Where | | --- | --- | --- | | Image pins | Yes, 1 image per pin | [Post types](#post-types) | | Video pins | Yes, 1 video per pin, with a custom cover | [Post types](#post-types) | | Choose or create a board | Yes | [Boards](#boards) | | Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit a published pin | Description only | [Edit a pin](#edit-a-published-pin) | | Analytics | Impressions, saves, clicks | [Analytics](#analytics) | | Inbox (DMs, comments) | No | Pinterest has no inbox API | | Ads | Yes, separate connection | [Pinterest Ads](https://pozy.ai/docs/ads/pinterest-ads) | There are no text-only pins, carousels or multi-image pins through the API. Every pin carries exactly one image or one video. ## Connect Pinterest connects with OAuth. Request a connect URL for a profile and send the user to `authUrl`: ```bash cURL curl "https://api.pozy.ai/v1/connect/pinterest?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/pinterest?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Redirect the user's browser to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/pinterest", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "redirect_url": "https://app.example.com/connected"}, ) print(resp.json()["authUrl"]) ``` After the Pinterest consent screen the user picks a default board. Pozy hosts that screen for you. To build your own picker, pass `headless=true` and use [List Pinterest boards](https://pozy.ai/docs/api/listPinterestBoardsForSelection) and [Select Pinterest board](https://pozy.ai/docs/api/selectPinterestBoard). See [Hosted connect](https://pozy.ai/docs/connect/hosted-connect) and [Headless connect](https://pozy.ai/docs/connect/headless). You can change the default board later with [Set default Pinterest board](https://pozy.ai/docs/api/updatePinterestBoards). No second OAuth is needed. ## Post types `content` becomes the pin description. The board, title and destination link go in `platformSpecificData` on the Pinterest entry. ### Image pin ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Small kitchen layouts that still fit an island", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/kitchen-2x3.jpg" }], "platforms": [{ "platform": "pinterest", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "boardId": "1022106146582011234", "title": "Small kitchen layouts with an island", "link": "https://blog.example.com/small-kitchens" } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Small kitchen layouts that still fit an island", mediaItems: [{ type: "image", url: "https://pozy.ai/media/kitchen-2x3.jpg" }], platforms: [{ platform: "pinterest", accountId: "66b2e19d8c3f5a7e9d0b1c2d", platformSpecificData: { boardId: "1022106146582011234", title: "Small kitchen layouts with an island", link: "https://blog.example.com/small-kitchens", }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Small kitchen layouts that still fit an island", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/kitchen-2x3.jpg"}], "platforms": [{ "platform": "pinterest", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "boardId": "1022106146582011234", "title": "Small kitchen layouts with an island", "link": "https://blog.example.com/small-kitchens", }, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` > **Always send boardId:** > > `boardId` is optional in the schema, but a pin without it lands on whichever board Pinterest lists first. If the account has no boards at all, the pin fails and asks for `platformSpecificData.boardId`. ### Video pin Swap the media for one video. Set a cover with `coverImageUrl`, or let Pozy grab a frame at `coverImageKeyFrameTime` seconds: ```json { "platform": "pinterest", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "boardId": "1022106146582011234", "title": "Five-minute breakfast wrap", "link": "https://recipes.example.com/breakfast-wrap", "coverImageUrl": "https://pozy.ai/media/wrap-cover.jpg" } } ``` ### Platform fields | Field | Type | Notes | | --- | --- | --- | | `boardId` | string | Board to pin to. Send it on every post. | | `boardSectionId` | string | Section inside the board. Must belong to `boardId`, so send both. | | `title` | string, up to 100 characters | Defaults to the first line of `content`. Searchable on Pinterest. | | `link` | URL | Where a click on the pin goes. HTTPS, no URL shorteners. This is the field that drives traffic. | | `coverImageUrl` | URL | Cover image for a video pin. | | `coverImageKeyFrameTime` | integer (seconds) | Frame to use as the cover. Ignored when `coverImageUrl` is set. | | `isAiGenerated` | boolean, default `false` | Adds Pinterest's "AI modified" disclosure. Pinterest may still label a pin from its own detection. | The full list of shared post fields is on [Platform settings](https://pozy.ai/docs/publishing/platform-settings). ## Boards List the boards on a connected account, or create one. A new board's `id` works as `boardId` straight away. ```bash cURL # List boards curl https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/pinterest-boards \ -H "Authorization: Bearer $POZY_API_KEY" # Create a board curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/pinterest-boards \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Summer recipes", "description": "Quick dinners for hot days", "privacy": "PUBLIC" }' ``` ```ts Node const listRes = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/pinterest-boards", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const list = await listRes.json(); if (!listRes.ok) throw new Error(list.error); const createdRes = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/pinterest-boards", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Summer recipes", description: "Quick dinners for hot days", privacy: "PUBLIC" }), }); const created = await createdRes.json(); if (!createdRes.ok) throw new Error(created.error); console.log(created.board.id); ``` ```python Python base = "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/pinterest-boards" headers = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} boards = requests.get(base, headers=headers).json()["boards"] created = requests.post(base, headers=headers, json={ "name": "Summer recipes", "description": "Quick dinners for hot days", "privacy": "PUBLIC", }).json() print(created["board"]["id"]) ``` `privacy` is `PUBLIC` (the default), `PROTECTED` or `SECRET`. Pinterest rejects a second board with the same name on one account. ## Edit a published pin [Edit published post](https://pozy.ai/docs/api/editPost) replaces the description of a live pin, up to 800 characters. The title, media, link and board cannot change after publishing, and the pin keeps its id. ```bash curl -X POST https://api.pozy.ai/v1/posts/65f1c0a9e2b5af0012ab34cd/edit \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "pinterest", "content": "Updated description with the new recipe notes" }' ``` > **Note:** > > Pinterest still keeps its pin-update API behind an allowlist. Until Pinterest opens it, edit requests come back with Pinterest's "beta feature not yet enabled" error. Nothing on your side changes that. If the post went to several Pinterest accounts, pass `accountId` to choose which copy to edit. ## Media requirements | Images | Requirement | | --- | --- | | Per pin | 1 | | Formats | JPEG, PNG, WebP, GIF (animated GIFs count as images) | | Max size | 32 MB (under 10 MB loads faster) | | Minimum | 100 x 100 px | | Best ratio | 2:3, for example 1000 x 1500 px. 1:1 and long pins up to 1:2.1 also work | | Videos | Requirement | | --- | --- | | Per pin | 1 | | Formats | MP4, MOV | | Max size | 2 GB | | Duration | 4 seconds to 15 minutes | | Aspect ratio | 2:3, 1:1 or 9:16 | | Recommended | 1080p, 25 fps or more, around 10 Mbps, AAC audio at 128 kbps | Media URLs must be public, return the file bytes with the right `Content-Type`, and not redirect to a web page. Sharing links from Google Drive, Dropbox, OneDrive or iCloud return a page, not a file, and fail. Upload through [Media](https://pozy.ai/docs/publishing/media) to get a URL that always works. ## Limits | Limit | Value | | --- | --- | | Title | 100 characters | | Description | 800 characters, on create and on edit | | Media | 1 image or 1 video per pin | | Burst posting | Space pins out. Bursts of 10 or more at once trip Pinterest's rate limit | Not available through Pinterest's API: Idea Pins, Rich Pins (they need meta tags on your site), website claiming, shopping catalogs, carousels and multi-image pins. ## Analytics [Get post analytics](https://pozy.ai/docs/api/getAnalytics) with `platform=pinterest` returns, per pin: | Metric | Field | | --- | --- | | Impressions | `impressions` | | Saves | `saves` | | Clicks | `clicks` | ```bash curl "https://api.pozy.ai/v1/analytics?platform=pinterest&fromDate=2026-09-01&toDate=2026-09-30" \ -H "Authorization: Bearer $POZY_API_KEY" ``` See [Post analytics](https://pozy.ai/docs/analytics/post-analytics) for paging and date ranges. ## Inbox Pinterest has no DM, comment or review API, so Pinterest accounts do not appear in the [Inbox](https://pozy.ai/docs/inbox). ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | "Pinterest requires a boardId" | No `boardId` and no board Pozy could fall back to | Send `platformSpecificData.boardId`. List boards first. | | "Unable to reach the URL" | Pinterest could not download the media | Open the URL in a private window. It must load the raw file with no login or redirect. | | "Invalid URL or request data" | Pinterest could not process the media or request | Check the URL is HTTPS and returns media bytes. | | "Pinterest rate limit reached" | Too many pins in a short window | Spread pins out or use a [queue](https://pozy.ai/docs/publishing/queues). | A post you publish right away can be accepted by Pozy and still fail on Pinterest. Check `post.status` and `platforms[].errorMessage` in the response, not only the HTTP status. [Errors](https://pozy.ai/docs/getting-started/errors) covers the error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/pinterest-boards`](https://pozy.ai/docs/api/getPinterestBoards) | List Pinterest boards | | [`POST /v1/accounts/{accountId}/pinterest-boards`](https://pozy.ai/docs/api/createPinterestBoard) | Create Pinterest board | | [`PUT /v1/accounts/{accountId}/pinterest-boards`](https://pozy.ai/docs/api/updatePinterestBoards) | Set default Pinterest board | | [`GET /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/listPinterestBoardsForSelection) | List Pinterest boards | | [`POST /v1/connect/pinterest/select-board`](https://pozy.ai/docs/api/selectPinterestBoard) | Select Pinterest board | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | --- # Bluesky Connect Bluesky with an app password, publish text, image, video and thread posts, tag the post language, and answer DMs and comments. Bluesky connects with an app password instead of OAuth, then behaves like any other account: you publish with `POST /v1/posts` using the platform id `bluesky`, and the same account shows up in the [Inbox](https://pozy.ai/docs/inbox) for DMs and comments. ## What you can do | Feature | Supported | Where | | --- | --- | --- | | Text, image and video posts | Yes | [Post types](#post-types) | | Threads | Yes, with `threadItems` | [Threads](#threads) | | Post language tags | Yes, per post or as an account default | [Post language](#post-language) | | Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Editing a published post | No | Bluesky has no post editing | | DMs | Text only | [Inbox](#inbox) | | Comments | List, reply, delete, like | [Inbox](#inbox) | | Analytics | Likes, comments, reposts | [Analytics](#analytics) | ## Connect The user creates an app password in Bluesky (Settings, Privacy and security, App passwords) and gives you their handle. Custom-domain handles such as `brand.com` work as well as `brand.bsky.social`. An app password looks like `xxxx-xxxx-xxxx-xxxx` and never exposes the main account password. The user can revoke it in Bluesky at any time. Send both to [Connect Bluesky account](https://pozy.ai/docs/api/connectBlueskyCredentials). `state` is `{userId}-{profileId}`, where `userId` is your user id from [GET /v1/users](https://pozy.ai/docs/api/listUsers) and `profileId` is the profile that should own the account. Sending only the profile id also works. ```bash cURL curl -X POST https://api.pozy.ai/v1/connect/bluesky/credentials \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "identifier": "acme.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop", "state": "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/bluesky/credentials", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ identifier: "acme.bsky.social", appPassword: "abcd-efgh-ijkl-mnop", state: "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.account._id); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/connect/bluesky/credentials", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "identifier": "acme.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop", "state": "66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4", }, ) print(resp.json()["account"]["_id"]) ``` > **Warning:** > > Collect the app password over HTTPS and send it straight to Pozy. Do not store it yourself. If the user pastes their main password instead, the connection fails. See [Credentials flows](https://pozy.ai/docs/connect/credentials) for the pattern and for building the form. There are no OAuth scopes on Bluesky. What the account can do is whatever the app password allows. ## Post types A post with only `content` is a text post. Add `mediaItems` for an image or video post. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New release notes are up: https://example.com/changelog", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/release.png", "altText": "The new settings screen" }], "platforms": [{ "platform": "bluesky", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "New release notes are up: https://example.com/changelog", mediaItems: [{ type: "image", url: "https://pozy.ai/media/release.png", altText: "The new settings screen" }], platforms: [{ platform: "bluesky", accountId: "66b2e19d8c3f5a7e9d0b1c2d" }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostUrl); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "New release notes are up: https://example.com/changelog", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/release.png", "altText": "The new settings screen"}], "platforms": [{"platform": "bluesky", "accountId": "66b2e19d8c3f5a7e9d0b1c2d"}], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostUrl"]) ``` Pozy turns plain text into Bluesky's rich-text links for you: - `@handle.bsky.social` becomes a mention. - `#tag` becomes a clickable hashtag. - A URL becomes a link, and Bluesky builds a preview card from the page's Open Graph tags. Put the URL at the end of the post and check the page has those tags. > **Cross-posting:** > > Bluesky's 300-character limit is strict and counts URLs and mentions. When the same post goes to platforms with longer limits, set `customContent` on the Bluesky entry with a shorter version. Otherwise only the Bluesky part fails. ### Threads `platformSpecificData.threadItems` publishes a chain. The first item is the root post, each later item replies to the one before. Every item has its own text, its own media and its own 300-character limit. When `threadItems` is set, the top-level `content` is kept for display and search in Pozy but is not published. ```json { "platform": "bluesky", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "threadItems": [ { "content": "Three things we changed in our API this quarter", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/api.png" }] }, { "content": "1. Cursor pagination on every list endpoint." }, { "content": "2. Idempotency keys on every create." }, { "content": "3. One error format everywhere." } ] } } ``` [Threads](https://pozy.ai/docs/publishing/threads) covers threads across platforms. ### Post language `langs` tags a post with 1 to 3 BCP-47 codes, such as `en` or `pt-BR`. Bluesky's language feeds filter on it, so an untagged post never shows up in them. Set it per post, or set an account default that applies whenever a post has none: ```bash cURL curl -X PATCH https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "defaultLangs": ["pt"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ defaultLangs: ["pt"] }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.patch( "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/bluesky-settings", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"defaultLangs": ["pt"]}, ) ``` A per-post `langs` always wins. Send `"defaultLangs": null` to clear the default. Every item of a thread gets the same languages. | Field | Type | Notes | | --- | --- | --- | | `langs` | array of string | 1 to 3 BCP-47 codes. Set at creation only. | | `threadItems` | array of `{ content, mediaItems }` | The whole thread, root first. | ## Media requirements | Images | Requirement | | --- | --- | | Per post | Up to 4 | | Formats | JPEG, PNG, WebP, GIF | | Max size | 1 MB per image. Larger images are compressed, which can cost quality | | Max dimensions | 2000 x 2000 px | | Recommended | 1200 x 675 px (16:9), 1000 x 1000 px (1:1) or 800 x 1000 px (4:5) | | Alt text | `altText` on each media item | | Videos | Requirement | | --- | --- | | Per post | 1 | | Format | MP4 only | | Max size | 50 MB | | Max duration | 60 seconds | | Max dimensions | 1920 x 1080 px | | Recommended | 1280 x 720 px, 30 fps, H.264 video, AAC audio | Videos outside these limits are rejected, not compressed. Compress large images yourself if quality matters. Hosting rules are on [Media](https://pozy.ai/docs/publishing/media). ## Limits | Limit | Value | | --- | --- | | Characters per post | 300, including URLs and mentions | | Characters per thread item | 300 each | | Images | 4 per post | | Videos | 1 per post | | Edits after publishing | Not possible | Not available through Bluesky's API: lists and starter packs, custom feeds, pinning a post to the profile, content warnings and labels, DM attachments, and follower or profile analytics. ## Analytics [Get post analytics](https://pozy.ai/docs/api/getAnalytics) with `platform=bluesky` returns engagement counts only: | Metric | Field | | --- | --- | | Likes | `likes` | | Replies | `comments` | | Reposts | `shares` | Bluesky does not report impressions, reach, clicks or views, so those fields are empty for Bluesky posts. ## Inbox | Feature | Supported | | --- | --- | | List and read conversations | Yes | | Send DMs | Text only (Bluesky's chat API has no attachments) | | Archive and unarchive | Yes | | List comments on your posts | Yes | | Reply to and delete comments | Yes | | Like and unlike a comment | Yes | To like a comment, pass the comment's `cid` from the comments endpoints to [Like comment](https://pozy.ai/docs/api/likeInboxComment). Keep the `likeUri` it returns, because [Unlike comment](https://pozy.ai/docs/api/unlikeInboxComment) needs it. See [Messages](https://pozy.ai/docs/inbox/messages) and [Comments](https://pozy.ai/docs/inbox/comments). ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | "Bluesky posts cannot exceed 300 characters" | `content` is over the limit | Shorten it, or set `customContent` on the Bluesky entry | | "Thread item N exceeds 300 characters" | One thread item is too long | Split it into more items | | Invalid app password on connect | The main password was used, or the app password was revoked | Create a new app password and connect again | | Image too large | An image is over 1 MB | Compress it before upload to control quality | | "Publishing failed due to max retries reached" | Bluesky kept refusing the request | Usually temporary. Retry the post | Check `post.status` and `platforms[].errorMessage` in the create response. A post can be accepted by Pozy and still fail on Bluesky. [Errors](https://pozy.ai/docs/getting-started/errors) has the error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/connect/bluesky/credentials`](https://pozy.ai/docs/api/connectBlueskyCredentials) | Connect Bluesky account | | [`GET /v1/accounts/{accountId}/bluesky-settings`](https://pozy.ai/docs/api/getBlueskySettings) | Get Bluesky account settings | | [`PATCH /v1/accounts/{accountId}/bluesky-settings`](https://pozy.ai/docs/api/updateBlueskySettings) | Update Bluesky account settings | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | | [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | | [`GET /v1/inbox/comments`](https://pozy.ai/docs/api/listInboxComments) | List commented posts | | [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment | | [`POST /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/likeInboxComment) | Like comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/unlikeInboxComment) | Unlike comment | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # Google Business Profile Publish updates, events and offers to a Google Business Profile location, reply to reviews and read location performance and search keywords. A Google Business Profile post shows up in Google Search, Google Maps and the business's knowledge panel, not in a feed. Posts help local ranking and fade after about a week, so a weekly rhythm works best. In Pozy the platform id is `googlebusiness`, and one connected account is one location. This page covers publishing, reviews and analytics. Listing management (hours, photos, services, menus, attributes, verification) lives in the [Google Business](https://pozy.ai/docs/google-business) section. ## What you can do | Feature | Supported | Where | | --- | --- | --- | | Standard updates (text, text with image) | Yes | [Post types](#post-types) | | Call-to-action buttons | Yes | [Call to action](#call-to-action) | | Event posts | Yes | [Events and offers](#events-and-offers) | | Offer posts | Yes | [Events and offers](#events-and-offers) | | Posting to several locations from one account | Yes, with `locationId` | [Several locations](#several-locations) | | Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit a published post | Body text only | [Editing](#edit-a-published-post) | | Reviews | List, reply, delete a reply | [Inbox](#inbox) | | Location analytics | Impressions, clicks, calls, directions, bookings, search keywords | [Analytics](#analytics) | | Listing management | Hours, media, services, menus, attributes, verification | [Google Business](https://pozy.ai/docs/google-business) | | Video posts, DMs, comments, Q&A | No | Not exposed by Google | ## Connect The location must be verified in Google. Request a connect URL and send the user to `authUrl`: ```bash cURL curl "https://api.pozy.ai/v1/connect/googlebusiness?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/googlebusiness?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Send the user's browser to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/googlebusiness", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "redirect_url": "https://app.example.com/connected"}, ) print(resp.json()["authUrl"]) ``` After Google's consent screen the user picks the location to connect. Pozy hosts that picker by default. To build it yourself, pass `headless=true` and call [List locations](https://pozy.ai/docs/api/listGoogleBusinessLocations) and [Select location](https://pozy.ai/docs/api/selectGoogleBusinessLocation). Once done, the user lands on your `redirect_url` with `connected=googlebusiness`, `profileId` and `accountId` appended. See [Hosted connect](https://pozy.ai/docs/connect/hosted-connect) and [Headless connect](https://pozy.ai/docs/connect/headless). | Connect error | Cause | Fix | | --- | --- | --- | | `402` on the connect URL call | The workspace needs a payment method before another account can connect | Add a card, then call again. See [Billing](https://pozy.ai/docs/billing/billing) | | Error listing locations | Google refused: the token is invalid or the user has no access to that Business Profile | Restart the connect flow so the user grants access again | | `404` on select location | The `locationId` is not one this connection manages | Use an `id` from the list call of the same flow | ## Post types A post with no `topicType` is a standard update. Send one image at most, and no video. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Open all weekend, 8am to 8pm. The autumn menu starts Friday.", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/autumn-menu.jpg" }], "platforms": [{ "platform": "googlebusiness", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Open all weekend, 8am to 8pm. The autumn menu starts Friday.", mediaItems: [{ type: "image", url: "https://pozy.ai/media/autumn-menu.jpg" }], platforms: [{ platform: "googlebusiness", accountId: "66b2e19d8c3f5a7e9d0b1c2d" }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].platformPostId); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Open all weekend, 8am to 8pm. The autumn menu starts Friday.", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/autumn-menu.jpg"}], "platforms": [{"platform": "googlebusiness", "accountId": "66b2e19d8c3f5a7e9d0b1c2d"}], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["platformPostId"]) ``` Text-only posts work, but posts with an image or a button get more visibility. To schedule instead, replace `publishNow` with `scheduledFor` and `timezone`. ### Call to action `callToAction` adds a button under the post. `url` must be HTTPS. ```json { "platform": "googlebusiness", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "callToAction": { "type": "BOOK", "url": "https://example.com/book" } } } ``` | `type` | Button | | --- | --- | | `LEARN_MORE` | Link to more information | | `BOOK` | Booking or reservation | | `ORDER` | Online ordering | | `SHOP` | Online store | | `SIGN_UP` | Registration | | `CALL` | Phone call to the business | ### Events and offers `topicType` picks the post type. `EVENT` needs an `event` with a `title` and a `schedule`. `OFFER` takes an `offer` and an optional `event` for the offer period. ```json { "platform": "googlebusiness", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Grand opening weekend", "schedule": { "startDate": { "year": 2026, "month": 11, "day": 14 }, "startTime": { "hours": 9, "minutes": 0 }, "endDate": { "year": 2026, "month": 11, "day": 15 }, "endTime": { "hours": 17, "minutes": 0 } } }, "callToAction": { "type": "LEARN_MORE", "url": "https://example.com/opening" } } } ``` ```json { "platform": "googlebusiness", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "topicType": "OFFER", "event": { "title": "20% off all pastries", "schedule": { "startDate": { "year": 2026, "month": 11, "day": 1 }, "endDate": { "year": 2026, "month": 11, "day": 30 } } }, "offer": { "couponCode": "AUTUMN20", "redeemOnlineUrl": "https://example.com/order", "termsConditions": "In store and online. One per customer." } } } ``` Dates are `{ year, month, day }` and times are `{ hours, minutes }` in 24-hour time. ISO 8601 strings are also accepted for all four and converted for you. The event title and dates show as the post heading on Google. More detail, including multi-location posting, is on [Google Business posts](https://pozy.ai/docs/google-business/posts). ### Platform fields | Field | Type | Notes | | --- | --- | --- | | `topicType` | `STANDARD`, `EVENT` or `OFFER` | Default `STANDARD` | | `callToAction` | `{ type, url }` | Button under the post | | `event` | `{ title, schedule }` | Required for `EVENT`, optional period for `OFFER` | | `offer` | `{ couponCode, redeemOnlineUrl, termsConditions }` | All optional | | `locationId` | string, `locations/{id}` | Post to another location the account manages | | `languageCode` | string | BCP 47 code. Metadata only, it does not translate | ### Several locations An account that manages several locations can post to the others with `locationId`. Get the ids with [List Google Business Profile locations](https://pozy.ai/docs/api/getGmbLocations). Change the account's default location with [Update location](https://pozy.ai/docs/api/updateGmbLocation). See [Google Business posts](https://pozy.ai/docs/google-business/posts) for posting one update to many locations. ## Edit a published post [Edit published post](https://pozy.ai/docs/api/editPost) with `platform: "googlebusiness"` replaces the body text. The image, button, event and offer details stay as published. There is no edit window and no edit limit. The `id` in the response is Google's post id, not the Pozy post id you put in the path. A `404` on edit means the post no longer exists on Google: it was deleted in Google's dashboard, or it was an event or offer whose end date passed. Publish a new post instead. ## Media requirements | Images | Requirement | | --- | --- | | Per post | 1 | | Video | Not supported | | Formats | JPEG, PNG. WebP is converted for you. GIF is not supported | | Max size | 5 MB. Larger images are compressed before sending | | Minimum | 400 x 300 px | | Recommended | 1200 x 900 px (4:3). Google crops, and 4:3 survives the crop best | The URL must be public HTTPS and return the file with no login or redirect. Upload through [Media](https://pozy.ai/docs/publishing/media) if you are not sure. ## Limits | Limit | Value | | --- | --- | | Post text | 1,500 characters | | Images | 1 per post | | Visibility | Roughly 7 days, then Google archives the post | | Time to appear | Posts can take 24 to 48 hours while Google reviews them | | Analytics history | Up to 18 months back | ## Analytics Google has retired per-post insights, so Business Profile analytics are per location. [Get performance metrics](https://pozy.ai/docs/api/getGoogleBusinessPerformance) returns daily values and totals for each metric. Pass `accountId` and a date range (it defaults to the last 30 days), and optionally `metrics` as a comma-separated list. Google's data runs 2 to 3 days behind. ```bash cURL curl "https://api.pozy.ai/v1/analytics/googlebusiness/performance?accountId=66b2e19d8c3f5a7e9d0b1c2d&startDate=2026-09-01&endDate=2026-09-30" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/googlebusiness/performance?accountId=66b2e19d8c3f5a7e9d0b1c2d&startDate=2026-09-01&endDate=2026-09-30", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.metrics.WEBSITE_CLICKS); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/analytics/googlebusiness/performance", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "66b2e19d8c3f5a7e9d0b1c2d", "startDate": "2026-09-01", "endDate": "2026-09-30"}, ) print(resp.json()["metrics"]["WEBSITE_CLICKS"]) ``` | Metric | Meaning | | --- | --- | | `BUSINESS_IMPRESSIONS_DESKTOP_SEARCH`, `BUSINESS_IMPRESSIONS_MOBILE_SEARCH` | Views in Google Search | | `BUSINESS_IMPRESSIONS_DESKTOP_MAPS`, `BUSINESS_IMPRESSIONS_MOBILE_MAPS` | Views in Google Maps | | `WEBSITE_CLICKS` | Clicks to the website | | `CALL_CLICKS` | Taps to call | | `BUSINESS_DIRECTION_REQUESTS` | Direction requests | | `BUSINESS_CONVERSATIONS` | Message conversations started | | `BUSINESS_BOOKINGS` | Bookings | | `BUSINESS_FOOD_ORDERS`, `BUSINESS_FOOD_MENU_CLICKS` | Food orders and menu clicks | [Get search keywords](https://pozy.ai/docs/api/getGoogleBusinessSearchKeywords) returns the searches that showed the listing, aggregated by month (`startMonth` and `endMonth` as `YYYY-MM`, default the last 3 months). Keywords below Google's privacy threshold come back with `impressions: 0` and `impressionsThreshold` set. Each page holds up to 100 keywords; pass `nextPageToken` back as `pageToken` for more. ## Inbox Reviews are the only inbox surface. List them with [List reviews](https://pozy.ai/docs/api/listInboxReviews) and `platform=googlebusiness`, filter by `accountId`, `minRating`, `maxRating` and `hasReply`, then answer with [Reply to review](https://pozy.ai/docs/api/replyToInboxReview). The review `id` is Google's full resource name, so it also identifies the location. ```bash curl "https://api.pozy.ai/v1/inbox/reviews?platform=googlebusiness&accountId=66b2e19d8c3f5a7e9d0b1c2d&hasReply=false" \ -H "Authorization: Bearer $POZY_API_KEY" ``` New, edited, removed and restored reviews arrive as webhooks. See [Reviews](https://pozy.ai/docs/inbox/reviews) and [Google Business reviews](https://pozy.ai/docs/google-business/reviews). ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | "Image not found" | The image URL is not reachable or needs a login | Use a public HTTPS URL that loads the raw file | | "Invalid image format" | Not JPEG or PNG, or the file is corrupted | Re-export as JPEG or PNG | | "Image too small" | Under 400 x 300 px | Use at least 400 x 300 px, ideally 1200 x 900 px | | Post not visible on Google | Still in Google's review, or the location is not verified | Wait up to 48 hours and check [verification](https://pozy.ai/docs/google-business/verifications) | | Button does nothing | The `callToAction.url` is invalid or shortened | Use a full HTTPS URL | | `401` with `token_invalid` | Google revoked or expired the token | Reconnect the account. See [Account health](https://pozy.ai/docs/connect/account-health) | [Errors](https://pozy.ai/docs/getting-started/errors) covers the error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/googlebusiness/locations`](https://pozy.ai/docs/api/listGoogleBusinessLocations) | List Google Business Profile locations | | [`POST /v1/connect/googlebusiness/select-location`](https://pozy.ai/docs/api/selectGoogleBusinessLocation) | Select Google Business Profile location | | [`GET /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/getGmbLocations) | List Google Business Profile locations | | [`PUT /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/updateGmbLocation) | Update Google Business Profile location | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`GET /v1/analytics/googlebusiness/performance`](https://pozy.ai/docs/api/getGoogleBusinessPerformance) | Get Google Business Profile performance metrics | | [`GET /v1/analytics/googlebusiness/search-keywords`](https://pozy.ai/docs/api/getGoogleBusinessSearchKeywords) | Get Google Business Profile search keywords | | [`GET /v1/inbox/reviews`](https://pozy.ai/docs/api/listInboxReviews) | List reviews | | [`POST /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToInboxReview) | Reply to review | | [`DELETE /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteInboxReviewReply) | Delete review reply | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | --- # Snapchat Publish Stories, Saved Stories and Spotlight videos to a Snapchat Public Profile and read views, reach and shares. In beta. Snapchat publishing goes to a Public Profile: a Story that lasts 24 hours, a Saved Story that stays on the profile, or a Spotlight video in Snapchat's public feed. You use the same `POST /v1/posts` call with the platform id `snapchat`. > **Snapchat is in beta:** > > Snapchat connections are not open to every workspace yet. A workspace without access gets `403` with code `PLATFORM_BETA_RESTRICTED` when it asks for a connect URL, before any Snapchat screen appears. Retrying does not help. Write to support@pozy.ai if you need Snapchat, and branch on `code`, not on the message text. ## What you can do | Feature | Supported | Where | | --- | --- | --- | | Story (24 hours, no caption) | Yes | [Post types](#post-types) | | Saved Story (permanent, with a title) | Yes | [Post types](#post-types) | | Spotlight (permanent video, with a description) | Yes | [Post types](#post-types) | | Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Analytics | Views, reach, shares | [Analytics](#analytics) | | DMs, comments | No | Snapchat's messaging API is closed to third-party apps | | Text-only posts, carousels | No | Every post needs exactly one image or video | ## Connect Only a Snapchat Public Profile (Person, Business or Official) can publish through the API. A regular account cannot. Request a connect URL with the platform `snapchat` and send the user to `authUrl`. After they authorize, they choose which Public Profile to connect. Pozy hosts that picker by default. With `headless=true` you build it yourself with [List Snapchat profiles](https://pozy.ai/docs/api/listSnapchatProfiles) and [Select Snapchat profile](https://pozy.ai/docs/api/selectSnapchatProfile). See [Hosted connect](https://pozy.ai/docs/connect/hosted-connect) and [Headless connect](https://pozy.ai/docs/connect/headless). ```bash curl "https://api.pozy.ai/v1/connect/snapchat?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ## Post types `platformSpecificData.contentType` picks the format. It defaults to `story`. | `contentType` | What it publishes | Lifetime | How `content` is used | | --- | --- | --- | --- | | `story` | A snap in the profile's Story | 24 hours | Ignored. Stories have no caption | | `saved_story` | A permanent story on the Public Profile | Permanent | Title, up to 45 characters | | `spotlight` | A video in the Spotlight feed (video only) | Permanent | Description, up to 160 characters including hashtags | ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes at the shoot #bts", "mediaItems": [{ "type": "video", "url": "https://pozy.ai/media/bts-vertical.mp4" }], "platforms": [{ "platform": "snapchat", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "contentType": "spotlight" } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Behind the scenes at the shoot #bts", mediaItems: [{ type: "video", url: "https://pozy.ai/media/bts-vertical.mp4" }], platforms: [{ platform: "snapchat", accountId: "66b2e19d8c3f5a7e9d0b1c2d", platformSpecificData: { contentType: "spotlight" }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.status); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": "Behind the scenes at the shoot #bts", "mediaItems": [{"type": "video", "url": "https://pozy.ai/media/bts-vertical.mp4"}], "platforms": [{ "platform": "snapchat", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": {"contentType": "spotlight"}, }], "publishNow": True, }, ) print(resp.json()["post"]["status"]) ``` For a Story, drop `contentType` (or set `"story"`) and send an image or a video. For a Saved Story, set `"saved_story"` and keep `content` under 45 characters. | Field | Type | Notes | | --- | --- | --- | | `contentType` | `story`, `saved_story` or `spotlight` | Default `story` | ## Media requirements Every post carries exactly one image or one video, in vertical 9:16. Files outside these limits are rejected, not compressed. | Images | Requirement | | --- | --- | | Formats | JPEG, PNG | | Max size | 20 MB | | Recommended | 1080 x 1920 px (9:16) | | Videos | Requirement | | --- | --- | | Format | MP4 | | Max size | 500 MB | | Duration | 5 to 60 seconds | | Minimum resolution | 540 x 960 px | | Recommended | 1080 x 1920 px (9:16) | Snapchat requires media to be encrypted before upload. Pozy does that for you, so you send a normal URL. Upload through [Media](https://pozy.ai/docs/publishing/media) for a URL that qualifies. ## Limits | Limit | Value | | --- | --- | | Media per post | Exactly 1 | | Saved Story title | 45 characters | | Spotlight description | 160 characters, hashtags included | | Spotlight media | Video only | Not available through Snapchat's API: AR lenses and filters, Snap Map, Snapchat sounds, collaborative stories, friends' stories, DMs and comments. Snapchat ads are not part of the Snapchat connection. ## Analytics [Get post analytics](https://pozy.ai/docs/api/getAnalytics) with `platform=snapchat` returns: | Metric | Field | | --- | --- | | Views | `views` | | Unique viewers | `reach` | | Shares | `shares` | `likes`, `comments` and `saves` are always `0`, because Snapchat has none of them. Screenshot counts and completion rates are not exposed. ## Inbox Snapchat accounts do not appear in the [Inbox](https://pozy.ai/docs/inbox). Snapchat does not give third-party apps access to DMs or snap replies. ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | `403` `PLATFORM_BETA_RESTRICTED` | Snapchat is not enabled for this workspace | Contact support@pozy.ai | | "Public Profile required" | The account has no Public Profile | Create one in Snapchat and choose it while connecting | | "Media is required" | No media on the post | Add one image or video | | "Only one media item supported" | More than one media item | Send a single file | | Video rejected | Duration, format, resolution or size is out of range | Check 5 to 60 seconds, MP4, at least 540 x 960 px, under 500 MB | | "Title too long" | Saved Story `content` over 45 characters | Shorten it | | "Description too long" | Spotlight `content` over 160 characters | Shorten it, hashtags included | [Errors](https://pozy.ai/docs/getting-started/errors) covers the error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/listSnapchatProfiles) | List Snapchat profiles | | [`POST /v1/connect/snapchat/select-profile`](https://pozy.ai/docs/api/selectSnapchatProfile) | Select Snapchat profile | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | --- # Reddit Reddit publishing, DMs and comments are paused while Reddit blocks third-party API access. What still works, what to expect, and the reference for when it returns. > **Reddit is paused:** > > Reddit has closed its API to third-party apps. Until Reddit restores access, publishing, DMs and comments through Pozy are paused: > > - The Reddit part of a post fails with a "Reddit is temporarily unavailable" error. Other platforms in the same post still publish. > - New Reddit messages, comments and analytics stop syncing. What was already synced stays readable. > - Reddit-only endpoints (subreddits, flairs, votes, search) return `503` with code `PLATFORM_DISABLED`. > - New Reddit connections are not possible. Accounts that are already connected stay connected, so you will not need to reconnect. > > [Reddit Ads](https://pozy.ai/docs/ads/reddit-ads) uses a separate connection and keeps working. When access comes back, Reddit turns on again without any action from you. Retry the posts that failed. The rest of this page is the reference for how Reddit behaves when it is available, so you can keep your integration ready. The platform id is `reddit`. ## What you can do | Feature | When available | Status now | | --- | --- | --- | | Text, link, image, gallery and native video posts | Yes | Paused | | Flair, NSFW and spoiler tags | Yes | Paused | | Edit a published text post | Body only | Paused | | DMs (Reddit private messages) | Text only | Paused, existing threads readable | | Comments | List, reply, edit, delete, vote | Paused, existing comments readable | | Search posts and browse subreddit feeds | Yes | Paused | | Analytics | Score and comment count | Last synced values only | | Reddit Ads | Separate connection | Working. See [Reddit Ads](https://pozy.ai/docs/ads/reddit-ads) | ## Connect Reddit connects with OAuth through [Get OAuth connect URL](https://pozy.ai/docs/api/getConnectUrl) with platform `reddit`. New connections are refused while Reddit is paused. A connected account has a default subreddit. [List Reddit subreddits](https://pozy.ai/docs/api/getRedditSubreddits) returns the subreddits it can post to, and [Set default subreddit](https://pozy.ai/docs/api/updateRedditSubreddits) changes the default without reconnecting. ## Post types Every post targets one subreddit, and each subreddit has its own rules. Check them with [Get subreddit rules](https://pozy.ai/docs/api/getSubredditRules) and [Check subreddit existence](https://pozy.ai/docs/api/validateSubreddit) before you submit. Many subreddits require a flair, block automated posting, or set karma and account-age minimums. New accounts are blocked from most. By default the first line of `content` is the title and the rest is the body, in Reddit Markdown. ```json { "platform": "reddit", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "subreddit": "learnprogramming", "title": "What helped me learn a second language faster", "flairId": "a1b2c3d4-0000-1111-2222-333344445555" } } ``` | Post type | How to make it | | --- | --- | | Text (self) post | Only `content`. The title is the first line unless you set `title` | | Link post | Set `url`. `content` becomes the title. If the subreddit is text-only, the post is retried as a text post with the URL in the body | | Image post | One image in `mediaItems` | | Gallery | 2 to 20 images. Images past the 20th are dropped without an error | | Native video | One MP4 in `mediaItems`. Uploaded to Reddit's player and transcoded to at most 1080p and 30 fps | | Video as a link | `nativeVideo: false` posts the video URL as a link instead | > **The title is permanent:** > > Reddit titles cannot be edited after posting. Only the body of a text post can change. ### Platform fields | Field | Type | Notes | | --- | --- | --- | | `subreddit` | string | Without the `r/` prefix. Defaults to the account's default subreddit | | `title` | string, up to 300 characters | Defaults to the first line of `content` | | `url` | URL | Makes a link post | | `forceSelf` | boolean | Text-only self post, skips media uploads | | `nativeVideo` | boolean, default `true` | `false` posts the video as an external link | | `videogif` | boolean | Silent looping clip, still uploaded natively | | `videoPosterUrl` | URL | Poster image for a native video. Defaults to the first frame | | `flairId` | string | From [List subreddit flairs](https://pozy.ai/docs/api/getRedditFlairs). If a subreddit needs a flair and you send none, the first available flair is used | | `flairText` | string | Free-text flair where the subreddit allows it. Ignored when `flairId` is set | | `nsfw` | boolean, default `false` | Marks the post as over 18 | | `spoiler` | boolean, default `false` | Only works if the subreddit has spoiler tags on | | `sendreplies` | boolean, default `true` | `false` keeps comment replies out of the account's Reddit inbox | For a native video with explanatory text, keep `forceSelf` unset. With body text present, an upload failure or a subreddit that blocks video fails the post instead of falling back to a link. ## Media requirements | Images | Requirement | | --- | --- | | Per post | 1, or 2 to 20 as a gallery | | Formats | JPEG, PNG, GIF | | Max size | 20 MB | | Recommended | 1200 x 628 px. 16:9, 4:3, 1:1 and 9:16 all work | | Videos | Requirement | | --- | --- | | Per post | 1 | | Format | MP4 | | Max size | 1 GB. Larger files reach Reddit unchanged and Reddit refuses them | | Resolution | Transcoded to at most 1080p and 30 fps, so upload at 1080p or lower | ## Limits | Limit | Value | | --- | --- | | Title | 300 characters, required, cannot be edited | | Body | 40,000 characters | | Gallery | 20 images | | New accounts | Around 10 posts a day | | Rate limits | Reddit limits per app, so a `429` can arrive even at low volume on your side. Wait the seconds in the message, then retry | Not available through Reddit's API: polls, crossposts, collections, live chat threads, awards, and separate upvote and downvote counts. ## Analytics [Get post analytics](https://pozy.ai/docs/api/getAnalytics) with `platform=reddit` returns the score (as `likes`) and the comment count. Reddit does not report impressions, reach, shares, clicks or views. While Reddit is paused, these keep their last synced values. ## Inbox When available: | Feature | Supported | | --- | --- | | List and read DMs | Yes | | Send DMs | Text only | | List comments on your posts | Yes | | Reply to, edit and delete your comments | Yes. Replies need the `subreddit` parameter | | Vote on posts and comments | Yes, with [Vote on a Reddit post or comment](https://pozy.ai/docs/api/voteRedditThing) | > **Votes must come from a person:** > > Reddit's terms require every vote to be one person's own action. Automated or agent-decided voting counts as vote manipulation and can cost the account its API access. [Search posts](https://pozy.ai/docs/api/searchReddit) and [Get subreddit feed](https://pozy.ai/docs/api/getRedditFeed) let you monitor communities for mentions. See [Mentions](https://pozy.ai/docs/inbox/mentions). ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | "Reddit is temporarily unavailable" | Reddit is paused | Retry after Reddit returns | | `503` `PLATFORM_DISABLED` | A Reddit-only endpoint while Reddit is paused | Retry after Reddit returns | | "SUBREDDIT_NOTALLOWED" | Only trusted members can post there | Build karma or choose another subreddit | | "NO_SELFS" | The subreddit takes links or images only | Set `url` or attach an image | | "SUBMIT_VALIDATION_FLAIR_REQUIRED" | The subreddit needs a flair | Pass a `flairId` from the flairs list | | "SUBREDDIT_NOEXIST" | Typo, or the subreddit is private | Check the spelling, drop the `r/` prefix | | "Reddit requires a subreddit" | No `subreddit` and no account default | Set `platformSpecificData.subreddit` | | "Reddit removed the post" | Moderators or AutoModerator removed it | Read the rules and adjust the content | [Errors](https://pozy.ai/docs/getting-started/errors) covers the error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/reddit/search`](https://pozy.ai/docs/api/searchReddit) | Search posts | | [`GET /v1/reddit/feed`](https://pozy.ai/docs/api/getRedditFeed) | Get subreddit feed | | [`GET /v1/reddit/comments/{postId}`](https://pozy.ai/docs/api/getRedditPostComments) | Get the comments of a Reddit post | | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/reddit-subreddits`](https://pozy.ai/docs/api/getRedditSubreddits) | List Reddit subreddits | | [`PUT /v1/accounts/{accountId}/reddit-subreddits`](https://pozy.ai/docs/api/updateRedditSubreddits) | Set default subreddit | | [`GET /v1/accounts/{accountId}/reddit-subreddits/{subreddit}/rules`](https://pozy.ai/docs/api/getSubredditRules) | Get subreddit rules | | [`GET /v1/accounts/{accountId}/reddit-flairs`](https://pozy.ai/docs/api/getRedditFlairs) | List subreddit flairs | | [`POST /v1/accounts/{accountId}/reddit-flairs`](https://pozy.ai/docs/api/setRedditPostFlair) | Set Reddit post flair | | [`POST /v1/accounts/{accountId}/reddit-vote`](https://pozy.ai/docs/api/voteRedditThing) | Vote on a Reddit post or comment | | [`GET /v1/tools/validate/subreddit`](https://pozy.ai/docs/api/validateSubreddit) | Check subreddit existence | ## Webhook events | Event | When it fires | | --- | --- | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | --- # WhatsApp Connect a WhatsApp Business number, send templates and replies, run broadcasts, flows, groups and calls, and know the 24-hour window and tier rules. WhatsApp in Pozy runs on Meta's WhatsApp Business Platform. You connect a WhatsApp Business number to a profile, then message customers from the [Inbox](https://pozy.ai/docs/inbox) API, send [broadcasts](https://pozy.ai/docs/whatsapp/broadcasts), and use templates, flows, groups and calling. The platform id is `whatsapp`. This page is the summary. The [WhatsApp section](https://pozy.ai/docs/whatsapp) has a page for each feature. ## What you can do | Feature | Supported | Where | | --- | --- | --- | | Text, media, documents, audio and voice notes | Yes | [Sending messages](#sending-messages) | | Templates (marketing, utility, authentication) | Yes | [Templates](https://pozy.ai/docs/whatsapp/templates) | | Interactive messages (lists, buttons, CTA links, flows) | Yes | [Flows](https://pozy.ai/docs/whatsapp/flows) | | Broadcasts, scheduled | Yes | [Broadcasts](https://pozy.ai/docs/whatsapp/broadcasts) | | Group chats | Yes, on Cloud API only numbers | [Groups](https://pozy.ai/docs/whatsapp/groups) | | Voice calling, inbound and outbound | Yes, on Cloud API only numbers | [Calling](https://pozy.ai/docs/whatsapp/calling) | | AI business agent | Yes | [Business agent](https://pozy.ai/docs/whatsapp/business-agent) | | Free test number | Yes | [Sandbox](https://pozy.ai/docs/whatsapp/sandbox) | | Click-to-WhatsApp ads | Yes | [Meta Ads](https://pozy.ai/docs/ads/meta-ads) | | Scheduling a single message | No | Schedule a broadcast instead | | Post analytics, comments | No | Delivery and read status per message only | ## Two rules that shape every send **The 24-hour window.** For 24 hours after a customer's last message you can send them anything. Outside that window, the first message must be an approved template. A free-form message outside the window fails with Meta's error 131047. **The messaging tier.** A new number starts on Meta's lowest tier: 250 unique contacts in 24 hours for business-initiated conversations. Meta raises the tier as you build history and keep quality up. Quality and tier changes arrive as the `whatsapp.account.quality_updated` webhook. ## Connect You need a WhatsApp Business Account inside a Meta Business account. A personal WhatsApp account cannot be connected. If the business has no WhatsApp Business Account yet, Meta creates one during signup. There are three ways in: | Route | When to use it | How | | --- | --- | --- | | Meta's Embedded Signup | The business connects its own number in the browser | [Get OAuth connect URL](https://pozy.ai/docs/api/getConnectUrl) with platform `whatsapp` | | System User token | You already manage the WhatsApp Business Account in Meta and want a headless connect | [Connect WhatsApp via credentials](https://pozy.ai/docs/api/connectWhatsAppCredentials) with `accessToken`, `wabaId` and `phoneNumberId` | | A number bought through Pozy | You want a new number that can also carry calls and SMS | [WhatsApp phone numbers](https://pozy.ai/docs/whatsapp/phone-numbers) | ```bash cURL curl "https://api.pozy.ai/v1/connect/whatsapp?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected&onboarding=api" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/whatsapp?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https:%2F%2Fapp.example.com%2Fconnected&onboarding=api", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Send the user's browser to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/whatsapp", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "redirect_url": "https://app.example.com/connected", "onboarding": "api"}, ) print(resp.json()["authUrl"]) ``` The user lands back on `redirect_url` with the new `accountId`, which every WhatsApp call takes from then on. ### Cloud API only or coexistence `onboarding` decides whether the number keeps working in the WhatsApp Business app on the phone: | `onboarding` | What happens | Trade-off | | --- | --- | --- | | `business_app` (the default when you leave it out) | Coexistence. The number stays in the WhatsApp Business app and also works on the API. The user scans a QR code Meta shows during signup | Throughput fixed at 20 messages per second. No group chats, no calling. Meta has no API to leave coexistence later | | `api` | Cloud API only. The number is used through the API | Full throughput, groups and calling available | A profile can hold several WhatsApp numbers, each its own account. One number can be live on one profile only. If the user picks a number that conflicts, they come back to your `redirect_url` with an `error` such as `one_whatsapp_per_profile`, `whatsapp_number_pinned_to_profile` or `whatsapp_number_already_connected`. See [Connect WhatsApp](https://pozy.ai/docs/whatsapp) for each fix and for the full signup walkthrough. ## Sending messages A new conversation starts with a template through [Create conversation](https://pozy.ai/docs/api/createInboxConversation). Replies inside the 24-hour window use [Send message](https://pozy.ai/docs/api/sendInboxMessage). ```bash cURL curl -X POST https://api.pozy.ai/v1/inbox/conversations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "participantId": "+13105551234", "templateName": "order_shipped", "templateLanguage": "en_US", "templateParams": ["Maria", "A-10423"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/conversations", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", participantId: "+13105551234", templateName: "order_shipped", templateLanguage: "en_US", templateParams: ["Maria", "A-10423"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.data.conversationId); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/inbox/conversations", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "participantId": "+13105551234", "templateName": "order_shipped", "templateLanguage": "en_US", "templateParams": ["Maria", "A-10423"], }, ) print(resp.json()["data"]["conversationId"]) ``` Once the customer replies, send free-form messages to that `conversationId`: ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "message": "Your parcel is with the courier. Tracking link below.", "attachmentUrl": "https://pozy.ai/media/label.pdf", "attachmentType": "file" } ``` What a message can carry: | Type | How | | --- | --- | | Text | `message`. Links get a preview unless `linkPreview: false` | | Image, video, audio, document | `attachmentUrl` and `attachmentType` (`image`, `video`, `audio`, `file`) | | Voice note | An OGG (Opus, mono) audio attachment with `voiceNote: true` | | Template | `template` with `name`, `language` and `components`, or the `templateName` fields on create | | Interactive | `interactive` with a `type` such as `list`, `cta_url`, `flow`, `location_request_message`, `voice_call`, `product`, `carousel` | Send an `Idempotency-Key` header so a retry never sends twice. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency). ## Media requirements | Type | Formats | Max size | | --- | --- | --- | | Images | JPEG, PNG | 5 MB | | Videos | MP4, 3GPP (H.264 video, AAC audio) | 16 MB | | Documents | PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT | 100 MB | | Audio | MP3, OGG (Opus), AMR, AAC | 16 MB | Every URL must be public with no login and return the file, not a web page. Use [Media](https://pozy.ai/docs/publishing/media) to host files. ## Limits | Limit | Value | | --- | --- | | Free-form messages | Only inside the 24-hour customer service window | | New number tier | 250 unique contacts in 24 hours, raised by Meta over time | | Messages to one recipient | About 10 a minute before Meta throttles that recipient | | Coexistence throughput | 20 messages per second | | Recipients | Must be on WhatsApp, written in E.164 (for example `+13105551234`) | ## Analytics WhatsApp has no post analytics. Delivery is tracked per message and per broadcast recipient through the `message.delivered`, `message.read` and `message.failed` webhooks. Conversation volume and response times are in [Inbox analytics](https://pozy.ai/docs/analytics/inbox-analytics). ## Inbox WhatsApp conversations live in the same [Inbox](https://pozy.ai/docs/inbox/messages) as every other channel. Contacts, custom fields and automations such as [workflows](https://pozy.ai/docs/automations/workflows) and [sequences](https://pozy.ai/docs/automations/sequences) work on WhatsApp. There are no comments. ## Pricing WhatsApp has two billers: - **Pozy** bills the connected account, outbound messages past the monthly free allowance, numbers you buy through Pozy, and the carrier leg of outbound calls. - **Meta** bills template delivery and its per-minute calling fee straight to the WhatsApp Business Account. Meta needs a payment method on that account, or it stops delivering templates once the free tier runs out. See [WhatsApp pricing](https://pozy.ai/docs/whatsapp/pricing) and [Pricing](https://pozy.ai/docs/billing). ## Errors you may see | Error | Cause | Fix | | --- | --- | --- | | 131047 "Re-engagement message" | The 24-hour window closed | Send an approved template | | 132001 "Template not found" | Name or language does not match | Use the exact name and language code (`en` is not `en_US`) | | 131021 "Invalid phone number" | Malformed number or not on WhatsApp | Use E.164 with the country code | | 131026 "Message undeliverable" | The recipient cannot receive it, or Meta is holding back marketing messages to them | Check the number. If other messages deliver, send it as a utility template | | 131056 "Too many messages sent to this recipient" | More than about 10 a minute to one person | Pace sends per recipient | | 131052 "Media download failed" | WhatsApp could not fetch the media URL | Serve the file from a public URL | | 131031 "Account locked" | Meta suspended the WhatsApp Business Account | Contact Meta support | | `(#200)` permission error on every send | The number has a two-step PIN that was not registered | [Register the number](https://pozy.ai/docs/api/registerWhatsAppNumber) with its PIN | A message that was accepted and later fails arrives as `message.failed` with the same Meta code. [Errors](https://pozy.ai/docs/getting-started/errors) covers Pozy's error format. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`POST /v1/connect/whatsapp/credentials`](https://pozy.ai/docs/api/connectWhatsAppCredentials) | Connect WhatsApp via credentials | | [`GET /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/listWhatsAppPhoneNumbers) | List numbers for selection | | [`POST /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/completeWhatsAppPhoneSelection) | Complete number selection | | [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | | [`GET /v1/whatsapp/templates`](https://pozy.ai/docs/api/getWhatsAppTemplates) | List templates | | [`POST /v1/whatsapp/templates`](https://pozy.ai/docs/api/createWhatsAppTemplate) | Create template | | [`POST /v1/broadcasts`](https://pozy.ai/docs/api/createBroadcast) | Create broadcast draft | | [`POST /v1/broadcasts/{broadcastId}/send`](https://pozy.ai/docs/api/sendBroadcast) | Send broadcast now | | [`GET /v1/whatsapp/business-profile`](https://pozy.ai/docs/api/getWhatsAppBusinessProfile) | Get business profile | | [`POST /v1/whatsapp/business-profile`](https://pozy.ai/docs/api/updateWhatsAppBusinessProfile) | Update business profile | | [`POST /v1/accounts/{accountId}/whatsapp/register`](https://pozy.ai/docs/api/registerWhatsAppNumber) | Register a connected WhatsApp number on the Cloud API | The full list is in the [API reference](https://pozy.ai/docs/api#whatsapp). ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. | | [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`whatsapp.template.status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-template-status-updated) | WhatsApp template status updated. | | [`whatsapp.template.category_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-template-category-updated) | WhatsApp template category updated. | | [`whatsapp.account.quality_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-quality-updated) | WhatsApp quality rating or messaging limit changed. | | [`whatsapp.account.status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-status-updated) | WhatsApp Business Account restricted or reinstated. | | [`whatsapp.account.alert_received`](https://pozy.ai/docs/webhooks/events#whatsapp-account-alert-received) | WhatsApp account alert received. | | [`whatsapp.account.name_status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-name-status-updated) | WhatsApp display-name review outcome. | --- # Telegram Coming soon. Connect a Telegram channel or group with an access code, publish text, photos, videos, documents and albums, edit messages, and run bot DMs and commands. > **Telegram is coming soon:** > > Telegram accounts cannot be connected yet. [`GET /v1/connect/telegram`](https://pozy.ai/docs/api/getConnectUrl) and the Telegram access-code endpoints answer `403` with `code: "feature_not_available"`. The rest of this page describes how Telegram will work once it opens, so you can plan for it. Watch the [changelog](https://pozy.ai/docs/changelog) for the launch. Telegram publishing goes through a bot that is an administrator of your channel or group. There is no OAuth: you connect with a short access code, then publish with `POST /v1/posts` using the platform id `telegram`. The same bot handles direct messages in the [Inbox](https://pozy.ai/docs/inbox) and the "/" command menu. ## What you will be able to do | Feature | Supported | Where | | --- | --- | --- | | Text messages with HTML or Markdown formatting | Yes | [Post types](#post-types) | | Photo, video and document messages | Yes | [Post types](#post-types) | | Albums, images and videos mixed | Up to 10 items | [Post types](#post-types) | | Silent and protected messages | Yes | [Platform fields](#platform-fields) | | Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) | | Edit a published message | Text or caption | [Editing](https://pozy.ai/docs/publishing/editing) | | DMs with the bot | Yes, with attachments and keyboards | [Inbox](#inbox) | | Bot command menu | Up to 100 commands | [Bot commands](#bot-commands) | | Comments, analytics | No | Not exposed by Telegram's Bot API | ## Connect > **Note:** > > Not available yet: these steps return `403 feature_not_available` until Telegram opens. The bot must be an administrator of the channel or group, with permission to post messages, before you connect. A bot that is a plain member, or an admin without the post permission, is the most common setup failure: every post then fails with "Bot is not a member of the channel". ### With an access code ### Generate a code Call [Generate Telegram code](https://pozy.ai/docs/api/getTelegramConnectStatus) with the profile that should own the account. The code is valid for 15 minutes. ```bash cURL curl "https://api.pozy.ai/v1/connect/telegram?profileId=66a1f0c2a4b9d3e8f1a2b3c4" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/telegram?profileId=66a1f0c2a4b9d3e8f1a2b3c4", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.code, data.botUsername, data.instructions); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/telegram", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66a1f0c2a4b9d3e8f1a2b3c4"}, ) data = resp.json() print(data["code"], data["botUsername"], data["instructions"]) ``` The response has `code`, `expiresAt`, `expiresIn`, `botUsername` and `instructions`. Show the user the bot's username and the instructions exactly as returned. ### The user sets up the bot In Telegram, the user: 1. Adds the bot (`botUsername`) to the channel as an administrator with the Post Messages permission, or to the group as an administrator. 2. Opens a private chat with the bot. 3. Sends the code followed by the channel username, for example ` @acmenews`. For a private channel with no username, they forward any message from the channel to the bot together with the code. ### Poll until connected Call [Check Telegram status](https://pozy.ai/docs/api/completeTelegramConnect) with the code every few seconds. `status` is `pending` until the bot receives the code, then `connected` with the new account, or `expired` after 15 minutes. ```bash curl -X PATCH "https://api.pozy.ai/v1/connect/telegram?code=$CODE" \ -H "Authorization: Bearer $POZY_API_KEY" ``` `account._id` in the `connected` response is the `accountId` for every call that follows. If the code expires, generate a new one. ### Directly with a chat id If the bot is already an administrator and you know the chat id, skip the code and call [Connect Telegram directly](https://pozy.ai/docs/api/initiateTelegramConnect): ```bash curl -X POST https://api.pozy.ai/v1/connect/telegram \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chatId": "@acmenews", "profileId": "66a1f0c2a4b9d3e8f1a2b3c4" }' ``` `chatId` is `@username` for a public channel. For a private channel or a group it is the numeric id; private channel ids start with `-100`, group ids are negative. One way to find a numeric id is to forward a message from the channel to an id-lookup bot in Telegram. In a channel, posts appear under the channel's name and logo. In a group, they appear as sent by the bot. ## Post types The media decides the message type: | `mediaItems` | Result | Text used as | | --- | --- | --- | | None | Text message | Message body, up to 4,096 characters | | 1 image, video or document | Photo, video or document message | Caption, up to 1,024 characters | | 2 to 10 items | Album (images and videos can mix) | Caption on the first item | ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Release 2.4 is out.\nRead the notes: changelog", "platforms": [{ "platform": "telegram", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": { "parseMode": "HTML", "disableNotification": true } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: 'Release 2.4 is out.\nRead the notes: changelog', platforms: [{ platform: "telegram", accountId: "66b2e19d8c3f5a7e9d0b1c2d", platformSpecificData: { parseMode: "HTML", disableNotification: true }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.post.platforms[0].status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/posts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "content": 'Release 2.4 is out.\nRead the notes: changelog', "platforms": [{ "platform": "telegram", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platformSpecificData": {"parseMode": "HTML", "disableNotification": True}, }], "publishNow": True, }, ) print(resp.json()["post"]["platforms"][0]["status"]) ``` ### Formatting `parseMode` sets how Telegram reads `content`. HTML is the default. | `parseMode` | Syntax | | --- | --- | | `HTML` | ``, ``, ``, ``, ``, `
`, `` |
| `Markdown` | `*bold*`, `_italic_`, `[link](https://...)`, backtick code |
| `MarkdownV2` | Adds `__underline__`, `~strikethrough~` and `\|\|spoiler\|\|`, but every `_ * [ ] ( ) ~ > # + - = \| { } . !` and backtick must be escaped with a backslash |

### Platform fields

| Field | Type | Notes |
| --- | --- | --- |
| `parseMode` | `HTML`, `Markdown` or `MarkdownV2` | Default `HTML` |
| `disableWebPagePreview` | boolean | No link preview |
| `disableNotification` | boolean | Deliver silently |
| `protectContent` | boolean | Recipients cannot forward or save the message |

Edit the text or caption of a published message with [Edit published post](https://pozy.ai/docs/api/editPost). The media cannot be changed.

## Media requirements

| Images | Requirement |
| --- | --- |
| Per album | Up to 10 |
| Formats | JPEG, PNG, GIF, WebP |
| Max size | 10 MB. Larger images are compressed |
| Max resolution | Width plus height up to 10,000 px. Larger images are downscaled |

| Videos | Requirement |
| --- | --- |
| Per album | Up to 10 |
| Formats | MP4, MOV (H.264 recommended) |
| Max size | 50 MB. Larger videos are compressed, up to a source size of 200 MB. Above that the file goes through untouched and Telegram refuses it |
| Duration | No limit |

URLs must be direct HTTPS links with no redirects. Telegram rejects HTTP and redirecting links with "Wrong file identifier/HTTP URL specified". [Media](https://pozy.ai/docs/publishing/media) gives you URLs that qualify.

## Limits

| Limit | Value |
| --- | --- |
| Text message | 4,096 characters |
| Caption | 1,024 characters |
| Album | 10 items |
| Bot commands | 100, names up to 32 characters (`a-z`, `0-9`, `_`, no slash), descriptions up to 256 |

## Analytics

Telegram's Bot API exposes no view or engagement counts, so Telegram has no analytics in Pozy.

## Inbox

People who message the bot show up in the [Inbox](https://pozy.ai/docs/inbox/messages).

| Feature | Supported |
| --- | --- |
| List and read conversations | Yes |
| Send text | Yes |
| Send images, videos, documents | Yes. Images up to 10 MB, videos and documents up to 50 MB |
| Edit sent messages | Text and inline keyboard |
| Inline keyboards | Buttons with callback data or URLs |
| Reply keyboards | One-time custom keyboards |
| Reply to a specific message | `replyTo` with the message id |
| Archive and unarchive | Yes |
| Read receipts, deletions | No. The Bot API does not report them |

When someone taps an inline keyboard button, the callback data arrives on the incoming message in `metadata.callbackData`. Reactions arrive as `reaction.received` when the bot is an admin of the chat; reactions in private chats are not delivered.

### Bot commands

Bot commands are the entries in the "/" menu people see when they chat with the bot. Manage them with [Get](https://pozy.ai/docs/api/getTelegramCommands), [Set](https://pozy.ai/docs/api/setTelegramCommands) and [Delete](https://pozy.ai/docs/api/deleteTelegramCommands) TG bot commands.

```bash
curl -X PUT https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/telegram-commands \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "commands": [{ "command": "help", "description": "How to reach support" }, { "command": "status", "description": "Check your order status" }] }'
```

See [Conversation starters](https://pozy.ai/docs/automations/conversation-starters) for menus and starters on other channels.

## Errors you may see

| Error | Cause | Fix |
| --- | --- | --- |
| "Bot is not a member of the channel" | The bot is not an admin, or lacks the post permission | Make it an administrator with Post Messages, then retry |
| `expired` on the status check | The code was not used within 15 minutes | Generate a new code |
| "Wrong file identifier/HTTP URL specified" | Media URL is HTTP, redirects or is not public | Use a direct HTTPS URL or upload through Media |
| Message too long | Text over 4,096 or caption over 1,024 characters | Shorten it, or send the text as its own message |
| Formatting error | Unescaped characters in `MarkdownV2`, or broken HTML | Escape the characters, or switch to `HTML` |

[Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/telegram`](https://pozy.ai/docs/api/getTelegramConnectStatus) | Generate Telegram code (not available yet) |
| [`PATCH /v1/connect/telegram`](https://pozy.ai/docs/api/completeTelegramConnect) | Check Telegram status (not available yet) |
| [`POST /v1/connect/telegram`](https://pozy.ai/docs/api/initiateTelegramConnect) | Connect Telegram directly (not available yet) |
| [`GET /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/getTelegramCommands) | Get TG bot commands |
| [`PUT /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/setTelegramCommands) | Set TG bot commands |
| [`DELETE /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/deleteTelegramCommands) | Delete TG bot commands |
| [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post |
| [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post |
| [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations |
| [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. |
| [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. |
| [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. |
| [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. |

---



# Discord

Coming soon. Install the bot in a Discord server, publish messages, embeds, polls, forum posts and threads, send DMs, and manage roles, pins and scheduled events.

> **Discord is coming soon:**
> 
> Discord accounts cannot be connected yet. [`GET /v1/connect/discord`](https://pozy.ai/docs/api/getConnectUrl) answers `403` with `code: "feature_not_available"`, so there is no Discord account for the other Discord endpoints to act on. The rest of this page describes how Discord will work once it opens, so you can plan for it. Watch the [changelog](https://pozy.ai/docs/changelog) for the launch.

Discord publishing runs through a hosted bot. The server owner installs it with OAuth, you bind a channel, and from then on you post with `POST /v1/posts` using the platform id `discord`. You never create a Discord application, request privileged intents or keep a gateway connection open. The same bot sends DMs and manages roles, members, pins and scheduled events over plain REST calls.

## What you will be able to do

| Feature | Supported | Where |
| --- | --- | --- |
| Messages with attachments | Up to 10 files | [Post types](#post-types) |
| Embeds | Up to 10 per message | [Embeds](#embeds) |
| Native polls | Yes | [Polls](#polls) |
| Forum posts and threads | Yes | [Forums and threads](#forums-and-threads) |
| Announcement crossposts | Yes | [Platform fields](#platform-fields) |
| Custom name and avatar per post | Yes | [Message identity](#message-identity) |
| Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) |
| Edit and delete published messages | Yes | [Edit and delete](#edit-and-delete) |
| DMs | Outbound only | [Inbox](#inbox) |
| Roles, members, pins, scheduled events | Yes | [Server management](#server-management) |
| Reading replies, comments, analytics | No | Not available without a gateway connection |

## Connect

> **Note:**
> 
> Not available yet: these steps return `403 feature_not_available` until Discord opens.

### Install the bot

Request a connect URL with platform `discord` and send the server admin to `authUrl`. They choose a server and authorize the bot.

```bash
curl "https://api.pozy.ai/v1/connect/discord?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

### Bind a channel

Call [Connect a Discord channel](https://pozy.ai/docs/api/connectDiscordChannel) with the `guildId` (server id) and the `channelId` that should receive posts. One connected account serves one channel. Send `channelIds` to connect several at once, or call again to add another.

```bash
curl -X POST https://api.pozy.ai/v1/connect/discord \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "guildId": "1180000000000000000", "channelId": "1180000000000000123" }'
```

The response carries the new `accountId`. [List Discord guild channels](https://pozy.ai/docs/api/getDiscordChannels) shows the channels the bot can see.

The bot asks for these permissions at install: Send Messages, Embed Links, Attach Files, Send Messages in Threads, Create Public Threads, Manage Messages (pins), Manage Roles and Manage Events.

> **Older installs:**
> 
> Servers that installed the bot before Manage Roles and Manage Events were part of the install do not have them, and the role and event endpoints return `502` there. Ask a server admin to re-invite the bot, or grant the permissions to the bot's role under Server Settings, Roles.

## Post types

A post with only `content` becomes a text message in the channel named by `platformSpecificData.channelId`, which is required.

```bash cURL
curl -X POST https://api.pozy.ai/v1/posts \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Patch 1.8 is live. Full notes below.",
    "platforms": [{
      "platform": "discord",
      "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
      "platformSpecificData": {
        "channelId": "1180000000000000123",
        "embeds": [{
          "title": "Patch 1.8",
          "description": "New maps, faster matchmaking, and 40 bug fixes.",
          "url": "https://example.com/patch-1-8",
          "color": 5793266
        }]
      }
    }],
    "publishNow": true
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    content: "Patch 1.8 is live. Full notes below.",
    platforms: [{
      platform: "discord",
      accountId: "66b2e19d8c3f5a7e9d0b1c2d",
      platformSpecificData: {
        channelId: "1180000000000000123",
        embeds: [{
          title: "Patch 1.8",
          description: "New maps, faster matchmaking, and 40 bug fixes.",
          url: "https://example.com/patch-1-8",
          color: 5793266,
        }],
      },
    }],
    publishNow: true,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.post.platforms[0].platformPostUrl);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "content": "Patch 1.8 is live. Full notes below.",
        "platforms": [{
            "platform": "discord",
            "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
            "platformSpecificData": {
                "channelId": "1180000000000000123",
                "embeds": [{
                    "title": "Patch 1.8",
                    "description": "New maps, faster matchmaking, and 40 bug fixes.",
                    "url": "https://example.com/patch-1-8",
                    "color": 5793266,
                }],
            },
        }],
        "publishNow": True,
    },
)
print(resp.json()["post"]["platforms"][0]["platformPostUrl"])
```

### Embeds

`embeds` sends up to 10 rich cards with or instead of `content`, with 6,000 characters across all embed fields. Each embed takes `title`, `description`, `url`, `color`, `image`, `thumbnail`, `footer`, `author` and `fields` (each `{ name, value, inline }`). `color` is a decimal integer, so convert hex first (`#5865F2` is `5793266`).

### Polls

`poll` creates a native Discord poll. A poll is a message on its own and cannot carry attachments.

```json
{
  "platform": "discord",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "platformSpecificData": {
    "channelId": "1180000000000000123",
    "poll": {
      "question": { "text": "Which map should we rework next?" },
      "answers": [
        { "poll_media": { "text": "Harbor" } },
        { "poll_media": { "text": "Canyon" } },
        { "poll_media": { "text": "Station" } }
      ],
      "duration": 48,
      "allow_multiselect": false
    }
  }
}
```

| Poll property | Value |
| --- | --- |
| Answers | Up to 10 |
| `duration` | 1 to 768 hours (32 days). Default 24 |
| `allow_multiselect` | Default `false` |

### Forums and threads

- **Forum channels** need `forumThreadName`, which becomes the thread title. `content` is the starter message. `forumAppliedTags` takes up to 5 tag ids from the forum's settings (Discord has no API to list them).
- **A thread under the message**: `threadFromMessage` with `name`, plus optional `autoArchiveDuration` (60, 1440, 4320 or 10080 minutes) and `rateLimitPerUser` (slow mode, 0 to 21,600 seconds).
- **A thread later, or with no message**: [Create a Discord public thread](https://pozy.ai/docs/api/createDiscordThread) with `name` and an optional `messageId`.

### Platform fields

| Field | Type | Notes |
| --- | --- | --- |
| `channelId` | string | Required. The target channel |
| `embeds` | array | Up to 10, 6,000 characters combined |
| `poll` | object | Cannot be combined with media |
| `crosspost` | boolean | Publish to every server following an announcement channel. No effect on other channels |
| `forumThreadName` | string | Required on forum channels |
| `forumAppliedTags` | array of string | Up to 5 tag ids |
| `threadFromMessage` | object | Open a thread under the message |
| `tts` | boolean | Discord reads the message aloud |
| `webhookUsername` | string | Display name for this post, 1 to 80 characters |
| `webhookAvatarUrl` | URL | Avatar for this post |

### Message identity

By default, messages post under the bot's own name and avatar. To post under your brand, set an account default once with [Update Discord settings](https://pozy.ai/docs/api/updateDiscordSettings), or override per post with `webhookUsername` and `webhookAvatarUrl`. The per-post values win.

```bash
curl -X PATCH https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/discord-settings \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhookUsername": "Acme Updates", "webhookAvatarUrl": "https://pozy.ai/media/acme-avatar.png" }'
```

A name must be 1 to 80 characters and cannot contain "clyde" or "discord". An empty string resets it to the default. The same call can move the account to another `channelId`.

### Edit and delete

[Edit published post](https://pozy.ai/docs/api/editPost) replaces the text of a published message, with no time limit, and keeps the message id. To remove a published message, call [Delete a Discord channel message](https://pozy.ai/docs/api/deleteDiscordMessage) with the `accountId`. Deleting a draft or scheduled post in Pozy removes it before it is sent.

## Media requirements

| Type | Formats | Max size |
| --- | --- | --- |
| Images | JPEG, PNG, GIF, WebP | 20 MiB by default, higher on Boosted servers |
| Videos | MP4 | 20 MiB by default, higher on Boosted servers |
| Documents | Any file | 20 MiB by default, higher on Boosted servers |

Up to 10 attachments per message, each a public HTTPS URL. A poll cannot carry attachments. Upload through [Media](https://pozy.ai/docs/publishing/media) for URLs that qualify.

## Limits

| Limit | Value |
| --- | --- |
| Message content | 2,000 characters |
| Embeds | 10 per message, 6,000 characters total |
| Attachments | 10 per message |
| Pinned messages | 50 per channel |
| Poll answers | 10 |

Discord's own rate limits are handled for you: Pozy backs off on Discord's `429` responses, so you do not need retry logic for them.

## Analytics

Discord's bot API reports no message metrics, so Discord has no analytics in Pozy.

## Inbox

Discord is outbound only. [Send a Discord Direct Message](https://pozy.ai/docs/api/sendDiscordDirectMessage) sends a 1:1 message from the bot, for onboarding, reminders or support pings. Replies are not received, because reading DMs needs a gateway connection. There are no comments.

```bash
curl -X POST https://api.pozy.ai/v1/discord/dms \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "userId": "1170000000000000456", "content": "Your beta access is ready. Check #welcome to get started." }'
```

`userId` is the recipient's numeric id, not a username. Find it with [Search Discord guild members](https://pozy.ai/docs/api/searchDiscordGuildMembers), which prefix-matches usernames and nicknames. The bot can only DM people who share a server with it and have not turned off DMs from non-friends. Each DM counts as an outbound message on your bill. See [Pricing](https://pozy.ai/docs/billing).

## Server management

| Job | Endpoints |
| --- | --- |
| Roles | List, create, edit and delete roles. Assign and remove a role on a member (not `@everyone`) |
| Members | List with cursor paging, search by name, get one |
| Pins | List, pin and unpin messages (50 per channel) |
| Scheduled events | Create, list, get, update and delete. `entity.type` is `external` (with `location` and `endsAt`), `voice` or `stage` (with `channelId`) |

## Errors you may see

| Error | Cause | Fix |
| --- | --- | --- |
| "Missing Access" | The bot cannot see `channelId` | Give the bot's role access to the channel. Check with List Discord guild channels |
| `502` on a role or event endpoint | Older install without Manage Roles or Manage Events | Re-invite the bot or grant the permission |
| `403` on a DM | The recipient shares no server with the bot or blocks DMs from non-friends | Only DM members of a server the bot is in |
| `400` when pinning | The channel already has 50 pins | Unpin one first |
| `400` on Discord settings | `channelId` is not a text, announcement or forum channel, or the bot cannot see it | Pick a channel of type 0, 5 or 15 that the bot can access |
| Empty member list | The install predates the members permission | Re-invite the bot |
| Webhook username rejected | Empty, over 80 characters, or contains "clyde" or "discord" | Pick another name |

A post can be accepted by Pozy and still fail on Discord. Check `post.status` and `platforms[].errorMessage` in the response. [Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/accounts/{accountId}/discord-settings`](https://pozy.ai/docs/api/getDiscordSettings) | Get Discord account settings |
| [`PATCH /v1/accounts/{accountId}/discord-settings`](https://pozy.ai/docs/api/updateDiscordSettings) | Update Discord settings |
| [`GET /v1/accounts/{accountId}/discord-channels`](https://pozy.ai/docs/api/getDiscordChannels) | List Discord guild channels |
| [`POST /v1/discord/dms`](https://pozy.ai/docs/api/sendDiscordDirectMessage) | Send a Discord Direct Message |
| [`GET /v1/discord/guilds/{guildId}/roles`](https://pozy.ai/docs/api/listDiscordGuildRoles) | List Discord guild roles |
| [`POST /v1/discord/guilds/{guildId}/roles`](https://pozy.ai/docs/api/createDiscordGuildRole) | Create a Discord guild role |
| [`PATCH /v1/discord/guilds/{guildId}/roles/{roleId}`](https://pozy.ai/docs/api/editDiscordGuildRole) | Edit a Discord guild role |
| [`DELETE /v1/discord/guilds/{guildId}/roles/{roleId}`](https://pozy.ai/docs/api/deleteDiscordGuildRole) | Delete a Discord guild role |
| [`GET /v1/discord/guilds/{guildId}/members`](https://pozy.ai/docs/api/listDiscordGuildMembers) | List Discord guild members |
| [`GET /v1/discord/guilds/{guildId}/members/search`](https://pozy.ai/docs/api/searchDiscordGuildMembers) | Search Discord guild members |
| [`GET /v1/discord/guilds/{guildId}/members/{userId}`](https://pozy.ai/docs/api/getDiscordGuildMember) | Get a Discord guild member |
| [`PUT /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}`](https://pozy.ai/docs/api/addDiscordMemberRole) | Assign a role to a guild member |
| [`DELETE /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}`](https://pozy.ai/docs/api/removeDiscordMemberRole) | Remove a role from a guild member |
| [`DELETE /v1/discord/channels/{channelId}/messages/{messageId}`](https://pozy.ai/docs/api/deleteDiscordMessage) | Delete a Discord channel message |
| [`POST /v1/discord/channels/{channelId}/messages/{messageId}/crosspost`](https://pozy.ai/docs/api/crosspostDiscordMessage) | Crosspost Discord message |
| [`POST /v1/discord/channels/{channelId}/threads`](https://pozy.ai/docs/api/createDiscordThread) | Create a Discord public thread |
| [`GET /v1/discord/channels/{channelId}/pins`](https://pozy.ai/docs/api/listDiscordPinnedMessages) | List pinned messages |
| [`PUT /v1/discord/channels/{channelId}/pins/{messageId}`](https://pozy.ai/docs/api/pinDiscordMessage) | Pin a Discord message |
| [`DELETE /v1/discord/channels/{channelId}/pins/{messageId}`](https://pozy.ai/docs/api/unpinDiscordMessage) | Unpin a Discord message |
| [`GET /v1/discord/guilds/{guildId}/events`](https://pozy.ai/docs/api/listDiscordScheduledEvents) | List Discord scheduled events |
| [`POST /v1/discord/guilds/{guildId}/events`](https://pozy.ai/docs/api/createDiscordScheduledEvent) | Create a Discord scheduled event |
| [`GET /v1/discord/guilds/{guildId}/events/{eventId}`](https://pozy.ai/docs/api/getDiscordScheduledEvent) | Get a Discord scheduled event |
| [`PATCH /v1/discord/guilds/{guildId}/events/{eventId}`](https://pozy.ai/docs/api/updateDiscordScheduledEvent) | Update a Discord scheduled event |
| [`DELETE /v1/discord/guilds/{guildId}/events/{eventId}`](https://pozy.ai/docs/api/deleteDiscordScheduledEvent) | Delete a Discord scheduled event |

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL |
| [`POST /v1/connect/discord`](https://pozy.ai/docs/api/connectDiscordChannel) | Connect a Discord channel (not available yet) |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. |
| [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. |
| [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. |
| [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. |
| [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. |

---



# Slack

Install the app in a Slack workspace, publish channel messages and thread replies with files, set the message identity, and handle DMs and mentions in the inbox.

Slack publishing runs through a hosted app. A workspace member installs it with OAuth, picks a channel, and that channel becomes an account you post to with `POST /v1/posts` using the platform id `slack`. You do not create a Slack app, host a bot or handle tokens. The same account receives DMs and mentions in the [Inbox](https://pozy.ai/docs/inbox) and can start a DM with any member of the workspace.

## What you can do

| Feature | Supported | Where |
| --- | --- | --- |
| Channel messages in Slack `mrkdwn` | Yes | [Post types](#post-types) |
| Thread replies | Yes, with `threadTs` | [Post types](#post-types) |
| Files | Up to 10 per post | [Media requirements](#media-requirements) |
| Link and media unfurling | On or off per post | [Platform fields](#platform-fields) |
| Custom name and avatar | Per post or per account | [Message identity](#message-identity) |
| Scheduling | Yes | [Scheduling](https://pozy.ai/docs/publishing/scheduling) |
| Edit and delete published messages | Yes | [Editing](https://pozy.ai/docs/publishing/editing) |
| DMs and mentions in the inbox | Yes | [Inbox](#inbox) |
| Start a DM with a member | Yes | [Inbox](#inbox) |
| Analytics | No | Slack has no message analytics API |

## Connect

Slack installs into a workspace, not a channel, so the flow ends with a channel choice. One connected account is one channel. To post in several channels, connect each as its own account.

### Start OAuth

Call [List Slack channels](https://pozy.ai/docs/api/listSlackChannels) with only `profileId` (and your `redirect_url`). With no `pendingDataToken` or `accountId`, it returns `authUrl` and `state`. Send the user to `authUrl` to approve the app.

```bash
curl "https://api.pozy.ai/v1/connect/slack?profileId=66a1f0c2a4b9d3e8f1a2b3c4&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

### Pick the channel

Pozy shows a channel picker by default. In headless mode (`headless=true`), the redirect back to you carries a `pendingDataToken`. Call the same endpoint with it to list the workspace's channels yourself: public and private, archived ones left out, up to 2,000. `isMember` says whether the app is already in a channel.

### Save it

Call [Connect a Slack channel](https://pozy.ai/docs/api/connectSlackChannel) with the `channelId` (or several in `channelIds`) and the `pendingDataToken`:

```bash
curl -X POST https://api.pozy.ai/v1/connect/slack \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "profileId": "66a1f0c2a4b9d3e8f1a2b3c4", "pendingDataToken": "'"$PENDING_DATA_TOKEN"'", "channelId": "C07ABCDEF12" }'
```

The response carries the new `accountId`. The channel now shows up in [List accounts](https://pozy.ai/docs/api/listAccounts) for that profile.

To add another channel from the same workspace, skip OAuth: call List Slack channels with an existing Slack `accountId`, then save with `accountId` instead of `pendingDataToken`.

Things that trip people up:

- **Private channels.** The app cannot join a private channel on its own. Someone in the channel must invite it first (type `/invite` in the channel and choose the app). Until then the channel does not appear in the list. Public channels are joined automatically.
- **Admin approval.** Some workspaces need an admin to approve new apps, so Slack may show a "request approval" screen.
- **Unlisted app warning.** Slack may say the app "has not been reviewed by Slack" during install. That is expected for apps that are not in the Slack Marketplace.

## Post types

A post with `content` becomes a message in the account's channel. There is no `channelId` field on a Slack post; sending one returns `400`. To post somewhere else, connect that channel and use its `accountId`.

```bash cURL
curl -X POST https://api.pozy.ai/v1/posts \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "*Deploy finished* for `api` v2.4.1. Release notes: ",
    "platforms": [{
      "platform": "slack",
      "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
      "platformSpecificData": { "unfurlLinks": false, "username": "Release Bot" }
    }],
    "publishNow": true
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    content: "*Deploy finished* for `api` v2.4.1. Release notes: ",
    platforms: [{
      platform: "slack",
      accountId: "66b2e19d8c3f5a7e9d0b1c2d",
      platformSpecificData: { unfurlLinks: false, username: "Release Bot" },
    }],
    publishNow: true,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.post.platforms[0].platformPostId);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "content": "*Deploy finished* for `api` v2.4.1. Release notes: ",
        "platforms": [{
            "platform": "slack",
            "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
            "platformSpecificData": {"unfurlLinks": False, "username": "Release Bot"},
        }],
        "publishNow": True,
    },
)
print(resp.json()["post"]["platforms"][0]["platformPostId"])
```

To reply inside a thread, pass the parent message's timestamp as `threadTs`, for example `"threadTs": "1503435956.000247"`. Scheduling happens in Pozy; Slack's own scheduler is not used.

### Platform fields

| Field | Type | Notes |
| --- | --- | --- |
| `threadTs` | string | Post as a reply in this thread |
| `unfurlLinks` | boolean, default `true` | Expand links into preview cards |
| `unfurlMedia` | boolean, default `true` | Expand media links into inline previews |
| `username` | string | Display name for this message |
| `iconUrl` | URL | Avatar for this message |

### Message identity

`username` and `iconUrl` change the name and avatar on one message. Set a default for every post from a channel with [Update Slack account settings](https://pozy.ai/docs/api/updateSlackSettings); the per-post fields still win. The override is cosmetic: Slack still shows an APP badge, and the app's own name stays in the sidebar.

## Media requirements

Up to 10 files per post, uploaded through Slack's file API with the text as the caption. Pozy does not compress or convert files for Slack, and there is no format allowlist: the bytes behind your URL reach Slack as they are.

Whether an upload succeeds depends on the workspace's upload policy and, on Slack's free plan, its storage cap. A file Slack refuses fails the post with Slack's message. Files are named from the media item: `type: "video"` uploads as `.mp4`, `type: "gif"` as `.gif`, anything else from `filename`. [Media](https://pozy.ai/docs/publishing/media) hosts files up to 5 GB.

## Limits

| Limit | Value |
| --- | --- |
| Message content | 40,000 characters. Slack truncates beyond this without an error |
| Editable content | 4,000 characters. Longer posts cannot be edited |
| Files | 10 per post |
| Channels per account | 1 |

## Analytics

Slack has no public message analytics API, so Slack has no analytics in Pozy.

## Inbox

Slack events arrive in real time. The inbox takes in:

- DMs to the app
- Channel messages that @-mention the app
- Replies in threads the app is part of, including under posts you published
- Emoji reactions, as `reaction.received`

Ordinary channel conversation is not stored. The inbox is not a mirror of the workspace.

Each incoming message fires `message.received`, with `metadata.threadTs` when it is a thread reply and `metadata.mentionsBot: true` when it mentions the app. That pair is enough for the usual agent rule: always answer DMs, answer in a channel only when mentioned, reply in the thread.

Reply with [Send message](https://pozy.ai/docs/api/sendInboxMessage); `replyTo` threads the reply. Reactions use [Add reaction](https://pozy.ai/docs/api/addMessageReaction) with a named Slack emoji such as `:thumbsup:`.

To start a DM, find the member with [List Slack workspace members](https://pozy.ai/docs/api/listSlackMembers) and pass their member id as `participantId` to [Create conversation](https://pozy.ai/docs/api/createInboxConversation):

```bash cURL
curl "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/slack-members?query=maria" \
  -H "Authorization: Bearer $POZY_API_KEY"

curl -X POST https://api.pozy.ai/v1/inbox/conversations \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "participantId": "U04ABCDE123", "message": "Hi Maria, your report is ready." }'
```
```ts Node
const membersRes = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/slack-members?query=maria", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const members = await membersRes.json();
if (!membersRes.ok) throw new Error(members.error);

const convoRes = await fetch("https://api.pozy.ai/v1/inbox/conversations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", participantId: "U04ABCDE123", message: "Hi Maria, your report is ready." }),
});
const convo = await convoRes.json();
if (!convoRes.ok) throw new Error(convo.error);
console.log(convo.data.conversationId);
```
```python Python
headers = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}
members = requests.get(
    "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/slack-members",
    headers=headers, params={"query": "maria"},
).json()

convo = requests.post(
    "https://api.pozy.ai/v1/inbox/conversations",
    headers=headers,
    json={"accountId": "66b2e19d8c3f5a7e9d0b1c2d", "participantId": "U04ABCDE123", "message": "Hi Maria, your report is ready."},
).json()
print(convo["data"]["conversationId"])
```

Bots, deactivated members and Slackbot are left out of the member lookup.

Constraints to plan for:

- No history backfill. The inbox starts when the channel connects.
- Messages from other bots appear for context but never trigger automations or webhooks.
- Attachments appear as links that open in Slack.
- People who DM the app become contacts; people talking in a channel do not. Built-in automations ([workflows](https://pozy.ai/docs/automations/workflows), [sequences](https://pozy.ai/docs/automations/sequences)) run on DMs only. For channels, react to `message.received` with your own logic.

## Errors you may see

| Error | Cause | Fix |
| --- | --- | --- |
| `400` with `channelId` on a post | Slack posts have no `channelId` field | Connect the channel and use its `accountId` |
| Private channel missing from the list | The app has not been invited | Invite the app in that channel, then list again |
| `missing_scope` on inbound messages | The workspace was connected before the inbox existed | Reconnect the workspace once so it grants the new scopes |
| Edit rejected | The post is over 4,000 characters | Publish a new message |
| Upload refused | The workspace's upload policy or storage cap | Ask the workspace admin, or send a link instead |
| `400` on a reaction | The emoji has no Slack name | Use a named emoji such as `:thumbsup:` |

[Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/slack`](https://pozy.ai/docs/api/listSlackChannels) | List Slack channels for the channel picker |
| [`POST /v1/connect/slack`](https://pozy.ai/docs/api/connectSlackChannel) | Connect a Slack channel |
| [`GET /v1/accounts/{accountId}/slack-settings`](https://pozy.ai/docs/api/getSlackSettings) | Get Slack account settings |
| [`PATCH /v1/accounts/{accountId}/slack-settings`](https://pozy.ai/docs/api/updateSlackSettings) | Update Slack account settings |
| [`GET /v1/accounts/{accountId}/slack-members`](https://pozy.ai/docs/api/listSlackMembers) | List Slack workspace members |
| [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post |
| [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post |
| [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations |
| [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation |
| [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message |
| [`POST /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions`](https://pozy.ai/docs/api/addMessageReaction) | Add reaction |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. |
| [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. |
| [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. |

---



# iMessage

Try iMessage on the sandbox line today. Ordering a dedicated sender is not available yet; this page also covers opt-in links, pacing rules for new contacts and rich iMessages.

> **Not available yet:**
> 
> Ordering an iMessage sender and registering one you already hold are not available yet: [`POST /v1/imessage/senders/order`](https://pozy.ai/docs/api/orderImessageSender) and [`POST /v1/imessage/senders`](https://pozy.ai/docs/api/registerImessageSender) answer `403` with `code: "feature_not_available"`. You can try iMessage on the [sandbox](#sandbox) line in the meantime. The rest of this page describes senders as they will work.

To use iMessage you order a sender of your own, registered with Apple, from the API or the dashboard. When it is ready it shows up as an `imessage` account, and its chats live in the [Inbox](https://pozy.ai/docs/inbox) next to WhatsApp, SMS and social DMs. People without iMessage still get your message, as SMS or RCS from the same number.

This page is the platform summary. Numbers, porting and compliance for other channels are in [Phone, SMS & voice](https://pozy.ai/docs/telephony).

## What you can do

| Feature | Supported | Where |
| --- | --- | --- |
| Order a sender (US or UK number, or an email address) | Not available yet | [Connect](#connect) |
| Free shared test line | Yes | [Sandbox](#sandbox) |
| Opt-in links that open Messages with prefilled text | Yes | [Bring contacts in](#bring-contacts-in) |
| Message people who never wrote to you | Yes, paced and warmed up | [New contacts](#new-contacts) |
| Text, media, voice notes, location, contact card | Yes | [Sending messages](#sending-messages) |
| Effects, subject line, replies, reactions | Yes | [Sending messages](#sending-messages) |
| Edit and unsend | Within Apple's windows | [Limits](#limits) |
| Group chats | Yes | [Inbox](#inbox) |
| SMS and RCS fallback | Yes, on ordered numbers | [Sending messages](#sending-messages) |
| Publishing, analytics | No | Messaging only |

## Connect

> **Not available yet:**
> 
> Ordering and registering senders are not available yet. Both calls below answer `403` with `code: "feature_not_available"`.

A sender is one dedicated number (or email address) on one profile. iMessage cannot run on a number you already have elsewhere, because Apple only registers numbers hosted for this service. You need a payment method on file and usage-based billing first.

```bash cURL
curl -X POST https://api.pozy.ai/v1/imessage/senders/order \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "66a1f0c2a4b9d3e8f1a2b3c4",
    "kind": "phone",
    "region": "US",
    "displayName": "Acme Support",
    "purchaseIntentId": "order-acme-support-1"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/imessage/senders/order", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    profileId: "66a1f0c2a4b9d3e8f1a2b3c4",
    kind: "phone",
    region: "US",
    displayName: "Acme Support",
    purchaseIntentId: "order-acme-support-1",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.sender.id, data.sender.status);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/imessage/senders/order",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "profileId": "66a1f0c2a4b9d3e8f1a2b3c4",
        "kind": "phone",
        "region": "US",
        "displayName": "Acme Support",
        "purchaseIntentId": "order-acme-support-1",
    },
)
sender = resp.json()["sender"]
print(sender["id"], sender["status"])
```

> **Always send purchaseIntentId:**
> 
> An order is never retried for you. Repeating the call with the same `purchaseIntentId` returns the existing order instead of buying a second number.

You get `202` back, with the sender `ordering` or `activating`. Activation finishes later on its own: check [Get iMessage sender status](https://pozy.ai/docs/api/getImessageSender) from time to time, or listen for the `account.connected` webhook, which includes the new account id. You are billed from the moment it is active.

| Option | How |
| --- | --- |
| A number that activates instantly | List the pool with [List instantly available iMessage numbers](https://pozy.ai/docs/api/listImessageAvailableNumbers) and pass the item's `id` as `availableNumberId`. Hold one for 3 minutes with [Reserve](https://pozy.ai/docs/api/reserveImessageAvailableNumber). The pool can be empty for a region |
| A number near an area (US) | Leave out `availableNumberId` and send `zipCode`. The carrier assigns a number, usually within hours |
| An email sender | `kind: "email"` with `emailName` and `emailDomain`. API only |
| A sender you already hold | [Register an iMessage sender](https://pozy.ai/docs/api/registerImessageSender) attaches it to a profile |

[Update an iMessage sender](https://pozy.ai/docs/api/updateImessageSender) sets the display name and the contact card (name and square photo) people see when they save the number. [Cancel](https://pozy.ai/docs/api/cancelImessageSender) takes the sender offline at once and stops the next month's charge. One sender per profile.

## Sandbox

Before you order, try iMessage on the shared sandbox line for free:

1. Add your own phone number or Apple ID email with [Add an iMessage sandbox contact](https://pozy.ai/docs/api/addImessageSandboxContact). It starts `pending`.
2. From that phone, send the returned `joinText` (for example `JOIN 4821`) to the sandbox line. `joinLink` opens Messages with it filled in.
3. The contact turns `active`. Everything you send to the line now arrives in your inbox and fires your webhooks.

The handle must be the one your device starts new conversations from (iPhone: Settings, Apps, Messages, Send & Receive). Sandbox rules: up to 5 contacts per user, a 24-hour reply window after the contact's last message, 50 messages a day, no starting conversations, no groups, and no verification codes, prices or marketing. It is free and does not count as a connected account.

## Bring contacts in

The safest way to start is to let the contact write first. It needs no pacing, has no content rules and records consent.

- Each sender has an `optInLink` (an `imessage://` link with prefilled text) and an `imessageLink` web page that opens Messages from any Apple device, useful in email.
- [Create a tracked iMessage opt-in link](https://pozy.ai/docs/api/createImessageOptInLink) returns `imessage`, `sms` and `whatsapp` links plus a hosted `url` that picks the right one for the device. When the contact sends the message, `message.received` carries `metadata.optIn: true` and your parameters in `metadata.optInParameters`, so you can attribute the conversation. Unused links expire after 24 hours.

An inbound STOP (also STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) opts the contact out and skips automations for that message. START, UNSTOP or SUBSCRIBE opts them back in. [List iMessage audience](https://pozy.ai/docs/api/listImessageAudience) lists everyone who has messaged your senders with their `subscribed` state, and [Subscribe or opt out](https://pozy.ai/docs/api/setImessageSubscription) changes it on your side.

## New contacts

An ordered sender can message someone who never wrote to it, with [Create conversation](https://pozy.ai/docs/api/createInboxConversation). Apple shows these messages with a Report Junk button, and reports can block a sender for good, so Pozy enforces the rules before anything is sent.

```json
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "participantId": "+13105551234",
  "message": "Hi Chris, this is Sam from Northwind Builders. I build websites for contractors. Would you like to see a couple of examples?"
}
```

| Rule | Value |
| --- | --- |
| First message | An introduction to one person. No links or bare domains, emails, phone numbers, prices or attachments. Breaking this returns `400` `invalid_content` and the message is not sent |
| Follow-ups before a reply | At most 2, at least 24 hours apart, held to the same content rule. A fourth message returns `409` `recipient_must_message_first` |
| Spacing | One new conversation every 15 minutes per sender |
| Warm-up: new conversations allowed in 24 hours | 2 on days 1 and 2, 5 on days 3 and 4, 10 on days 5 to 7, 20 on days 8 to 14, 30 on days 15 to 21, then 50 |
| Contacts silent for 24 hours | Each sender waits at least 2 minutes between messages to them |

A send past a limit gets `429` `new_contact_limit`, together with the time you may try again. Write like a person talking to one other person. Sales pitches, made-up deadlines and anything that looks like a mass mailing get flagged by recipients. Watch your reply rate, and stop writing to someone who has left three messages unanswered. As soon as a contact answers, the limits no longer apply to that chat.

## Sending messages

Replies use the same [Send message](https://pozy.ai/docs/api/sendInboxMessage) call as every channel, with the sender's `accountId`:

```json
{
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "message": "Your table for four is confirmed for *Friday at 8pm*.",
  "subject": "Booking confirmed",
  "effect": "confetti"
}
```

| Feature | How |
| --- | --- |
| Text | `message`, under 10,000 characters. `*bold*`, `_italic_`, `__underline__` and `~strikethrough~` render as rich text |
| Attachments | 10 per message at most, each given as a public HTTPS URL no longer than 256 characters |
| Voice note | Send audio (mp3, wav, m4a, caf or aac) as an attachment and set `voiceNote: true` |
| Contact card | `contactCard: true` shares the sender's card. Set it on the sender first |
| Subject | `subject` shows as a bold line above the text |
| Effects | `effect`: `slam`, `loud`, `gentle`, `invisibleInk`, `echo`, `spotlight`, `balloons`, `confetti`, `love`, `lasers`, `fireworks`, `shootingStar`, `celebration` |
| Link previews | On by default. `linkPreview: false` sends plain links |
| Channel | Set `channel` to `imessage`, `sms`, `rcs` or `whatsapp` to force one for this send; without it Pozy chooses for each contact |
| Replies | `replyTo` with the message id |

`subject`, `effect`, `contactCard` and `channel` only work on iMessage and return `400` elsewhere. Typing indicators and read receipts work inside an active two-way conversation.

## Limits

| Limit | Value |
| --- | --- |
| Text | Under 10,000 characters |
| Attachments | 10 per message |
| Edit window | 15 minutes after sending ([Edit message](https://pozy.ai/docs/api/editInboxMessage)) |
| Unsend window | 2 minutes after sending ([Delete message](https://pozy.ai/docs/api/deleteInboxMessage)) |
| Unique contacts | 1,000 per sender per day |
| Senders | 1 per profile |
| Regions | US and UK numbers |

Past the edit or unsend window, the calls return `409` `edit_window_expired` or `unsend_window_expired`.

## Inbox

Inbound messages fire `message.received`. `metadata` includes the channel the message arrived on, the message type, the reply thread id, a detected language, the transcript of a voice message and any rich-text markup as `markdownText`. Inbound media is copied to Pozy storage so the links keep working. Apple's six tapbacks render natively, other emoji are sent as custom tapbacks (iOS 18 and later), and inbound reactions arrive as `reaction.received`.

Group threads arrive as their own conversation with `isGroup: true`. Start one with [Start an iMessage group chat](https://pozy.ai/docs/api/createImessageGroup) (asynchronous; the thread appears with the first group webhook), and rename it or add and remove people with the group endpoints.

## Pricing

A sender has a flat monthly fee, charged at activation and on the 1st of each month while it is active, with no per-message fee. Its `imessage` account also counts as a connected account. The first month and any month already started are not refundable. See [Pricing](https://pozy.ai/docs/billing) for current prices.

## Errors you may see

| Status | Code | Meaning | Fix |
| --- | --- | --- | --- |
| `400` | `invalid_content` | The opening message contains a link, an email address, a phone number, a price or an attachment | Take it out; the error names what was caught |
| `429` | `new_contact_limit` | Spacing, warm-up or follow-up limit | Retry after the time in the error |
| `409` | `recipient_must_message_first` | You sent three messages that got no answer, or this sender is not allowed to start chats | Wait until they reply, or send them an opt-in link |
| `409` | `recipient_opted_out` | The contact asked to stop | Only their START lets you write again |
| `400` | `sender_unavailable` | The sender is not active yet, or you sent `contactCard` before setting up a card | Check the sender's status, or set up the card first |
| `400` | `recipient_unreachable` | Not a mobile, or the contact blocks all messages | Check the handle |
| `402` | `payment_method_required` | No card on file | Add a payment method |
| `422` | `usage_billing_required` | Billing is not usage-based | Turn on usage-based billing for the workspace |
| `409` | `imessage_sender_conflict` | Another profile already has this sender | Cancel that sender before ordering again |
| `403` | `imessage_sender_limit` | Your sender cap is reached | Cancel a sender you no longer use |

[Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/imessage/senders`](https://pozy.ai/docs/api/listImessageSenders) | List iMessage senders |
| [`POST /v1/imessage/senders`](https://pozy.ai/docs/api/registerImessageSender) | Register an iMessage sender (not available yet) |
| [`GET /v1/imessage/senders/order`](https://pozy.ai/docs/api/listImessageSenderOrders) | List iMessage sender orders |
| [`POST /v1/imessage/senders/order`](https://pozy.ai/docs/api/orderImessageSender) | Order a new iMessage sender (not available yet) |
| [`GET /v1/imessage/senders/{senderId}`](https://pozy.ai/docs/api/getImessageSender) | Get iMessage sender status |
| [`PATCH /v1/imessage/senders/{senderId}`](https://pozy.ai/docs/api/updateImessageSender) | Update an iMessage sender |
| [`DELETE /v1/imessage/senders/{senderId}`](https://pozy.ai/docs/api/cancelImessageSender) | Cancel an iMessage sender |
| [`GET /v1/imessage/audience`](https://pozy.ai/docs/api/listImessageAudience) | List iMessage audience |
| [`GET /v1/imessage/sandbox/contacts`](https://pozy.ai/docs/api/listImessageSandboxContacts) | List iMessage sandbox contacts |
| [`POST /v1/imessage/sandbox/contacts`](https://pozy.ai/docs/api/addImessageSandboxContact) | Add an iMessage sandbox contact |
| [`DELETE /v1/imessage/sandbox/contacts/{contactId}`](https://pozy.ai/docs/api/removeImessageSandboxContact) | Remove an iMessage sandbox contact |
| [`POST /v1/imessage/audience/subscription`](https://pozy.ai/docs/api/setImessageSubscription) | Subscribe or opt out an iMessage contact |
| [`POST /v1/imessage/senders/{senderId}/opt-in-links`](https://pozy.ai/docs/api/createImessageOptInLink) | Create a tracked iMessage opt-in link |
| [`GET /v1/imessage/senders/available-numbers`](https://pozy.ai/docs/api/listImessageAvailableNumbers) | List instantly available iMessage numbers |
| [`POST /v1/imessage/senders/available-numbers/{numberId}/reserve`](https://pozy.ai/docs/api/reserveImessageAvailableNumber) | Reserve an available iMessage number |
| [`POST /v1/imessage/groups`](https://pozy.ai/docs/api/createImessageGroup) | Start an iMessage group chat |
| [`GET /v1/imessage/groups/{conversationId}`](https://pozy.ai/docs/api/getImessageGroup) | Get an iMessage group |
| [`PATCH /v1/imessage/groups/{conversationId}`](https://pozy.ai/docs/api/updateImessageGroup) | Rename an iMessage group or change its photo |
| [`POST /v1/imessage/groups/{conversationId}/participants`](https://pozy.ai/docs/api/addImessageGroupParticipant) | Add a participant to an iMessage group |
| [`DELETE /v1/imessage/groups/{conversationId}/participants`](https://pozy.ai/docs/api/removeImessageGroupParticipant) | Remove a participant from an iMessage group |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. |
| [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. |
| [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. |
| [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. |
| [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. |
| [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. |
| [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. |
| [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. |

---



# SMS

Send and receive SMS and MMS from your Pozy numbers, register US numbers with carriers, send from a brand name abroad, and handle opt-outs and delivery webhooks.

SMS in Pozy sends from phone numbers you buy or port into Pozy. Outbound messages go through `POST /v1/sms/messages`, replies land in the same [Inbox](https://pozy.ai/docs/inbox) as every other channel, and delivery arrives as webhooks. The platform id is `sms`.

This page is the summary. Number buying, porting, US carrier registration and sender IDs each have a full page in [Phone, SMS & voice](https://pozy.ai/docs/telephony).

## What you can do

| Feature | Supported | Where |
| --- | --- | --- |
| Send SMS and MMS | Yes | [Sending messages](#sending-messages) |
| Schedule a single SMS | Yes, with `sendAt` | [Sending messages](#sending-messages) |
| Two-way conversations in the inbox | Yes | [Inbox](#inbox) |
| Bulk sends | Yes, as a broadcast | [Broadcasts](https://pozy.ai/docs/automations/broadcasts) |
| US 10DLC and toll-free registration | Not available yet | [10DLC](https://pozy.ai/docs/telephony/10dlc) |
| Send from a brand name (outside the US) | Yes, one way | [Sender IDs](https://pozy.ai/docs/telephony/sender-ids) |
| Carrier and line-type lookup | Yes | [Look up a number](#look-up-a-number) |
| One-time passcodes | Not available yet | [Verify endpoints](https://pozy.ai/docs/api/createVerification) |
| Publishing, analytics | No | Messaging only |

## Connect

There is no OAuth. SMS needs usage-based billing and an SMS-capable number:

### Get a number

[Search available numbers](https://pozy.ai/docs/api/searchAvailablePhoneNumbers) with `sms=true` and [purchase](https://pozy.ai/docs/api/purchasePhoneNumber) one with `wantsSms: true`, or [port in](https://pozy.ai/docs/telephony/porting) a number you already have. See [Buy a number](https://pozy.ai/docs/telephony/buy-a-number).

### Turn on SMS

Call [Enable SMS on a number](https://pozy.ai/docs/api/enableSmsOnNumber). The number can receive texts from this moment.

### Register it (US only)

US numbers stay silent until their carrier registration, 10DLC or toll-free, is approved. US carrier registration is not available yet, so for now US numbers can receive texts but not send them. When it opens, begin with [Start a carrier registration](https://pozy.ai/docs/api/startSmsRegistration) as early as you can, because approval is slow. Extra numbers can join a registration you already have approved, without paying again, through [Add number to SMS registration](https://pozy.ai/docs/api/reuseSmsRegistrationForNumber). Outside the US, numbers send as soon as SMS is enabled.

## Sending messages

```bash cURL
curl -X POST https://api.pozy.ai/v1/sms/messages \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reminder-appt-88213" \
  -d '{
    "from": "+14155550123",
    "to": "+13105551234",
    "text": "Reminder: your appointment is tomorrow at 10:00. Reply C to confirm."
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/sms/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": "reminder-appt-88213",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "+14155550123",
    to: "+13105551234",
    text: "Reminder: your appointment is tomorrow at 10:00. Reply C to confirm.",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.id, data.conversationId);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/sms/messages",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": "reminder-appt-88213",
    },
    json={
        "from": "+14155550123",
        "to": "+13105551234",
        "text": "Reminder: your appointment is tomorrow at 10:00. Reply C to confirm.",
    },
)
print(resp.json())
```

| Field | Notes |
| --- | --- |
| `from` | A number you own with SMS enabled, or a [sender ID](https://pozy.ai/docs/telephony/sender-ids). Normalized to E.164, so any formatting works |
| `to` | The recipient, normalized to E.164 |
| `text` | Up to 10 segments. Optional when you send media |
| `mediaUrls` | Up to 10 public URLs. Sends an MMS |
| `sendAt` | ISO 8601 time with an offset, in the future, to schedule the message |

The response has the message `id` and the inbox `conversationId`. Send an `Idempotency-Key` so a retry replays the original response instead of texting twice. Reusing a key with a different body returns `422`; a key that is still in flight returns `409`.

To text many people, create an SMS [broadcast](https://pozy.ai/docs/automations/broadcasts). Its attachments go out as MMS media.

## Media requirements

| Item | Requirement |
| --- | --- |
| MMS media | Up to 10 public `mediaUrls` per message |
| Broadcast attachments | Up to 10 public http(s) URLs with no redirects. JPEG, PNG, GIF, WEBP, MP4 or 3GPP, under 1 MB each |
| Sender IDs | No MMS |

## Limits

| Limit | Value |
| --- | --- |
| Text length | 10 segments: 1,530 GSM-7 characters or 670 Unicode characters |
| Segment size | GSM-7: 160 characters, or 153 per part when split. Unicode (emoji, non-Latin scripts): 70, or 67 per part |
| US sending | Only after the number's carrier registration is `approved` |
| Destinations | Only countries on the SMS price list. Others are blocked at send time |
| Sender ID names | 3 to 11 characters, letters, digits and spaces, at least one letter. Up to 1,000 active per workspace |
| Sender ID daily limit | Resets at midnight UTC. Write to support@pozy.ai to raise your sender ID limit |
| Opt-out list | `limit` defaults to 500, up to 5,000, newest first |

Sender IDs cannot receive replies, cannot send MMS, and cannot send to the US, Canada or Puerto Rico. Prices are per segment and set by the destination carrier after sending, so you cannot get a quote before a send.

### Opt-outs

A recipient who replies STOP is opted out by the carrier. Further sends to them return `409`; they are never dropped silently. Only the recipient can opt back in, by replying START. Keep your own list in sync with [List SMS opt-outs](https://pozy.ai/docs/api/listSmsOptOuts) (add `format=csv` for a file).

### Look up a number

[Look up carrier and line type](https://pozy.ai/docs/api/lookupSmsNumber) returns `carrierName`, `lineType` (`mobile`, `landline`, `voip`, `toll-free` or `unknown`) and `smsReachable`. Each lookup is billed by the carrier-data provider, so run it when you clean a list, not before every send.

## Analytics

There are no post analytics for SMS. Each message reports its outcome as a webhook. Conversation volume and response times are in [Inbox analytics](https://pozy.ai/docs/analytics/inbox-analytics).

## Inbox

Replies thread into one conversation per contact and number in the [Inbox](https://pozy.ai/docs/inbox/messages), with `platform: "sms"`.

| Event | When |
| --- | --- |
| `message.received` | An inbound text arrives |
| `conversation.started` | The first message of a new thread |
| `message.delivered` | The carrier confirmed delivery |
| `message.failed` | Delivery failed after the message was accepted, with the carrier's error code |

You can also reply from the inbox with [Send message](https://pozy.ai/docs/api/sendInboxMessage).

## Pricing

SMS is billed per segment by destination, plus the monthly price of each number and, in the US, the 10DLC brand and campaign fees. See [Pricing](https://pozy.ai/docs/billing) for the current table.

## Errors you may see

| Status | Code | Cause | Fix |
| --- | --- | --- | --- |
| `403` | | The US number's registration is still pending | Wait until it is `approved`; incoming texts already arrive |
| `404` | | `from` is not an SMS-enabled number you own | Send from one of your numbers |
| `409` | | The recipient replied STOP, or the same `Idempotency-Key` is in flight | Remove the recipient, or wait for the first request |
| `422` | | The same `Idempotency-Key` came with a different body | Every new message needs its own key |
| `403` | `alpha_destination_not_supported` | Sender IDs cannot text the US, Canada or Puerto Rico | Use a phone number as the sender |
| `403` | `mms_not_supported` | Sender IDs cannot send `mediaUrls` | Drop the media, or send from a phone number |
| `429` | `alpha_daily_limit_reached` | Today's sender ID limit is reached | Try after midnight UTC, or write to support@pozy.ai to raise your sender ID limit |
| `400` | `missing_required_field` | A registration is missing a field, named in `param` | Add it. [Pre-check](https://pozy.ai/docs/api/preflightSmsRegistration) catches every gap at once |
| `402` | | No payment method on file | Add one |

[Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) | Send an SMS/MMS |
| [`GET /v1/sms/lookup`](https://pozy.ai/docs/api/lookupSmsNumber) | Look up carrier + line type |
| [`GET /v1/sms/opt-outs`](https://pozy.ai/docs/api/listSmsOptOuts) | List SMS opt-outs |
| [`GET /v1/sms/sender-ids`](https://pozy.ai/docs/api/listSmsSenderIds) | List alphanumeric sender IDs |
| [`POST /v1/sms/sender-ids`](https://pozy.ai/docs/api/createSmsSenderId) | Create an alphanumeric sender ID |
| [`POST /v1/sms/sender-ids/limit-request`](https://pozy.ai/docs/api/requestSmsSenderIdLimitIncrease) | Request a higher sender ID daily limit (not available yet) |
| [`DELETE /v1/sms/sender-ids/{id}`](https://pozy.ai/docs/api/deleteSmsSenderId) | Delete an alphanumeric sender ID |
| [`GET /v1/sms/registrations`](https://pozy.ai/docs/api/listSmsRegistrations) | List carrier registrations |
| [`POST /v1/sms/registrations`](https://pozy.ai/docs/api/startSmsRegistration) | Start a carrier registration (not available yet) |
| [`POST /v1/sms/registrations/preflight`](https://pozy.ai/docs/api/preflightSmsRegistration) | Pre-check a carrier registration |
| [`GET /v1/sms/registrations/{id}`](https://pozy.ai/docs/api/getSmsRegistration) | Get a carrier registration |
| [`DELETE /v1/sms/registrations/{id}`](https://pozy.ai/docs/api/deactivateSmsRegistration) | Deactivate a brand/campaign registration |
| [`POST /v1/sms/registrations/{id}/verify-otp`](https://pozy.ai/docs/api/verifySmsRegistrationOtp) | Submit the sole-prop OTP |
| [`POST /v1/sms/registrations/{id}/resend-otp`](https://pozy.ai/docs/api/resendSmsRegistrationOtp) | Re-send the sole-prop OTP |
| [`POST /v1/sms/registrations/{id}/appeal`](https://pozy.ai/docs/api/appealSmsRegistration) | Appeal a rejected campaign |
| [`POST /v1/sms/registrations/{id}/respond`](https://pozy.ai/docs/api/respondToSmsRegistrationReview) | Reply to a change request |
| [`POST /v1/sms/opt-in-proof`](https://pozy.ai/docs/api/uploadSmsOptInProofFile) | Upload opt-in form proof |
| [`POST /v1/sms/registrations/{id}/opt-in-proof`](https://pozy.ai/docs/api/uploadSmsOptInProof) | Upload opt-in form proof for an appeal |
| [`POST /v1/sms/registrations/share`](https://pozy.ai/docs/api/shareSmsRegistration) | Create a registration share link (not available yet) |
| [`POST /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/enableSmsOnNumber) | Enable SMS on a number |
| [`DELETE /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/disableSmsOnNumber) | Disable SMS on a number |
| [`POST /v1/phone-numbers/{id}/sms/reuse-registration`](https://pozy.ai/docs/api/reuseSmsRegistrationForNumber) | Add number to SMS registration (not available yet) |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. |
| [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. |
| [`sms.registration.action_required`](https://pozy.ai/docs/webhooks/events#sms-registration-action-required) | SMS registration action required. |
| [`sms.registration.status_updated`](https://pozy.ai/docs/webhooks/events#sms-registration-status-updated) | SMS registration status updated. |

---



# RCS

Register a verified RCS agent, send rich cards, carousels and buttons to Android and iPhone, fall back to SMS, and receive replies and receipts in the inbox.

RCS is rich messaging for phones: Android users get it in Google Messages, and iPhone users in Messages on iOS 18 and later. Your messages arrive under a checked business identity with your name, logo, banner and a verified badge, not from an anonymous number. They can carry cards, carousels and buttons, and you see when they are read. In Pozy you register an RCS agent, then send with `POST /v1/rcs/messages`. Replies land in the [Inbox](https://pozy.ai/docs/inbox) with the platform id `rcs`.

## What you can do

| Feature | Supported | Where |
| --- | --- | --- |
| Register a brand and agent with the carriers | Yes, through the API | [Connect](#connect) |
| Test on your own phones before launch | Yes (US) | [Connect](#connect) |
| Text with buttons | Yes | [Sending messages](#sending-messages) |
| Media, rich cards, carousels | Yes | [Sending messages](#sending-messages) |
| SMS fallback for phones without RCS | Yes, with `smsFallbackFrom` | [SMS fallback](#sms-fallback) |
| Check which numbers can receive RCS | Up to 100 at a time | [SMS fallback](#sms-fallback) |
| Replies, button taps, locations, files | Yes | [Inbox](#inbox) |
| Delivery and read receipts | Yes | [Inbox](#inbox) |
| Publishing, analytics | No | Messaging only |

## Connect

Carriers approve two things before an agent can send:

- **Brand**: the company that is legally responsible for what is sent, with its registered name, tax id, address and a contact person. Several agents can share one brand.
- **Agent**: the sender identity on the phone, with a display name, description, logo, banner, colour and contact buttons. An agent sits in one profile, which gets an inbox account for it.

The workspace needs a plan with the inbox, usage-based billing and a saved card. Pozy reviews every request first; carriers see nothing and you pay nothing before that review is done.

### Upload the logo and banner

The carriers' sizes are strict: the logo is 224 x 224 and at most 50 KB, the banner 1440 x 448 and at most 200 KB. You do not have to hit them yourself. Send a PNG, JPEG or WebP to [Upload an RCS logo or banner](https://pozy.ai/docs/api/uploadRcsAsset) with `kind: "logo"` or `kind: "banner"`; Pozy crops and compresses it and returns the URL to use.

### Request the agent

Then call [Request an RCS agent](https://pozy.ai/docs/api/createRcsAgent). Send the profile, the agent's details, and the brand: a full `brand` object for a new one, or the `brandId` of an earlier one, but not both. A profile can have only one agent in progress or live at a time.

```json
{
  "profileId": "66a1f0c2a4b9d3e8f1a2b3c4",
  "displayName": "Acme Deliveries",
  "useCase": "TRANSACTIONAL",
  "brand": {
    "displayName": "Acme",
    "legalName": "Acme Logistics LLC",
    "legalEntityType": "LIMITED_LIABILITY_COMPANY",
    "organizationType": "PRIVATE_PROFIT",
    "websiteUrl": "https://acme.example.com",
    "taxId": "12-3456789",
    "address": { "line1": "100 Market St", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" },
    "contact": { "firstName": "Dana", "lastName": "Lee", "email": "dana@acme.example.com", "phone": "+14155550100" }
  },
  "profile": {
    "description": "Delivery updates from Acme",
    "logoUrl": "https://pozy.ai/media/acme-logo.png",
    "heroUrl": "https://pozy.ai/media/acme-banner.png",
    "brandColor": "#1A3D8F",
    "privacyPolicyUrl": "https://acme.example.com/privacy",
    "termsUrl": "https://acme.example.com/terms",
    "phone": { "number": "+14155550100", "label": "Call us" }
  }
}
```

### Fix anything the review flags

If something needs changing, the agent moves to `changes_requested` with a note in `reviewNote`. Send only the changed fields with [Update an RCS agent](https://pozy.ai/docs/api/updateRcsAgent) and it goes back into review. Once details are filed with the carriers they are locked; only `smsFallbackFrom` can still change.

### Test on your phones (US)

When a US agent reaches `testing`, it can message phones you invite, and no one else. Invite one with [Invite an RCS test phone](https://pozy.ai/docs/api/addRcsTestDevice) and accept the invite in that phone's messaging app. T-Mobile and AT&T numbers cannot be test phones.

### Send the launch details

[Send the launch filing](https://pozy.ai/docs/api/requestRcsAgentLaunch) tells the carriers what the agent will send and how people agree to it. It needs three or more `messageExamples`; the `consent` block, with how people opt in and the replies to opt-in, HELP and opt-out (for an opt-in on a website, add the page in `callToActionUrl` and a screenshot of it in `callToActionMediaUrl`); and `testVideoUrl`, a public or unlisted recording of a phone texting START, STOP and HELP to the agent and having one real exchange. For US agents, file it after the agent works on your test phones. In other countries, file it straight after the request.

Follow progress with the `rcs.agent.status_updated` webhook rather than polling.

| Market | Status path | Typical time |
| --- | --- | --- |
| United States | `requested`, `brand_vetting`, `agent_review`, `testing`, `launch_review`, `launching`, `live` | 4 to 6 weeks of carrier review |
| Other markets | `requested`, `brand_vetting`, `launching`, `live` | Varies by carrier |

Side exits are `changes_requested`, `rejected` (with `declineReason`) and `deactivated`. The launch market is the country of the brand's address. Supported countries include the US, UK, Austria, Bangladesh, Brazil, DR Congo, France, Germany, India, Italy, Mexico, the Netherlands, Nigeria, Norway, Portugal, Singapore, South Africa, Spain and Sweden. Spanish carriers have paused new launches for now; Spanish agents are filed once they reopen.

## Sending messages

You can send once the agent is `testing` (to accepted test phones) or `live` (to any RCS phone). Pass `text` for a plain message or `content` for anything richer.

```bash cURL
curl -X POST https://api.pozy.ai/v1/rcs/messages \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: delivery-A10423-out" \
  -d '{
    "agentId": "66c4d2e1f0a9b8c7d6e5f4a3",
    "to": "+13105551234",
    "content": {
      "type": "text",
      "text": "Your parcel A-10423 is out for delivery today.",
      "suggestions": [
        { "type": "reply", "text": "Track it", "postbackData": "track:A-10423" },
        { "type": "openUrl", "text": "Change delivery", "url": "https://acme.example.com/d/A-10423" },
        { "type": "dial", "text": "Call driver", "phoneNumber": "+14155550142" }
      ]
    },
    "fallbackText": "Your parcel A-10423 is out for delivery today. Track: https://acme.example.com/d/A-10423"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/rcs/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": "delivery-A10423-out",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agentId: "66c4d2e1f0a9b8c7d6e5f4a3",
    to: "+13105551234",
    content: {
      type: "text",
      text: "Your parcel A-10423 is out for delivery today.",
      suggestions: [
        { type: "reply", text: "Track it", postbackData: "track:A-10423" },
        { type: "openUrl", text: "Change delivery", url: "https://acme.example.com/d/A-10423" },
        { type: "dial", text: "Call driver", phoneNumber: "+14155550142" },
      ],
    },
    fallbackText: "Your parcel A-10423 is out for delivery today. Track: https://acme.example.com/d/A-10423",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.id, data.conversationId);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/rcs/messages",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": "delivery-A10423-out",
    },
    json={
        "agentId": "66c4d2e1f0a9b8c7d6e5f4a3",
        "to": "+13105551234",
        "content": {
            "type": "text",
            "text": "Your parcel A-10423 is out for delivery today.",
            "suggestions": [
                {"type": "reply", "text": "Track it", "postbackData": "track:A-10423"},
                {"type": "openUrl", "text": "Change delivery", "url": "https://acme.example.com/d/A-10423"},
                {"type": "dial", "text": "Call driver", "phoneNumber": "+14155550142"},
            ],
        },
        "fallbackText": "Your parcel A-10423 is out for delivery today. Track: https://acme.example.com/d/A-10423",
    },
)
print(resp.json())
```

`content.type` picks the layout. Every type takes up to 11 `suggestions` (buttons) under the message.

| `content.type` | What it sends | Options |
| --- | --- | --- |
| `text` | Text, up to 3,072 characters | `suggestions` |
| `media` | An image or video on its own | `media.url`, `thumbnailUrl`, `height` (`SHORT`, `MEDIUM`, `TALL`) |
| `card` | Media, a title (200 characters), a description (2,000) and up to 4 buttons on the card | `orientation` (`VERTICAL`, `HORIZONTAL`), `thumbnailAlignment` |
| `carousel` | 2 to 10 cards to swipe through | `cardWidth` (`SMALL`, `MEDIUM`) |

| Button `type` | On tap | Also send |
| --- | --- | --- |
| `reply` | The person replies with the button's label | `postbackData` |
| `openUrl` | A web page opens | `url`; optionally `application` (`BROWSER` or `WEBVIEW`) and `webviewViewMode` |
| `dial` | Starts a call | `phoneNumber` in E.164 |
| `viewLocation` | Opens a map pin | `latitude`, `longitude`, optional `label` |
| `shareLocation` | The phone offers to send its location | Nothing |
| `calendarEvent` | The phone offers to save an event | `startTime`, `endTime`, `title` (100 characters); optionally `description` (500) |

Keep labels to 25 characters. `postbackData` is your own string of up to 2,048 characters (JSON works) that Pozy hands back as is on a tap; without it you get the label. Set `ttlSeconds` if a message that has not been delivered by then should be dropped.

### SMS fallback

Set `smsFallbackFrom` on the agent to one of your numbers that can text, and phones without RCS still get the message, as an SMS from that number. The SMS body is your `fallbackText` if you send one; if not, Pozy uses the message text, or for a card its title, description and media link. With no fallback number, phones without RCS get nothing.

To check ahead of time, call [Check RCS capability](https://pozy.ai/docs/api/getRcsCapabilities) with up to 100 numbers. Each result has `rcsCapable` and the rich `features` the phone supports.

## Media requirements

| Item | Requirement |
| --- | --- |
| Media | A public URL for the image or video, with an optional `thumbnailUrl` |
| Agent logo | 224 x 224 px, under 50 KB (uploads are cropped and compressed to fit) |
| Agent banner | 1440 x 448 px, under 200 KB |
| Brand colour | A six-digit hex colour that reaches a 4.5:1 contrast ratio against white |

## Limits

| Limit | Value |
| --- | --- |
| Buttons | 11 per message, labels up to 25 characters |
| Card | Title 200 characters, description 2,000, 4 buttons |
| Carousel | 2 to 10 cards |
| Capability check | 100 numbers per call |
| Agent profile | `displayName` up to 40 characters, `description` up to 100 |
| Agents | One open agent per profile |

A few things are refused. You cannot send while the agent is in neither `testing` nor `live`. The brand contact cannot be a Gmail address or a generic `info@` or `support@` mailbox. The contact buttons need more than a website. Replies sent from the inbox are plain RCS text only; for cards and buttons, use `POST /v1/rcs/messages`.

## Analytics

RCS has no post analytics. Each message reports delivery and reads as webhooks, and conversation metrics are in [Inbox analytics](https://pozy.ai/docs/analytics/inbox-analytics).

## Inbox

Replies thread into the agent's inbox conversation, keyed by the person's phone number, with `platform: "rcs"`. Every inbound message fires `message.received`; the first message of a new thread also fires `conversation.started`.

| Incoming | Where it shows up |
| --- | --- |
| A text | `text` |
| A tapped `reply` button | The label in both `text` and `metadata.postbackTitle`, and the button's `postbackData` in `metadata.postbackPayload` |
| A shared location | `latitude` and `longitude` under `metadata.location` |
| A photo, video or file | `attachments`, stored by Pozy |

Match on `postbackPayload`, not the label: labels are for people and may change. Your sends report `message.delivered`, `message.read` and `message.failed`, including messages that went out through the SMS fallback. STOP (and STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) opts the person out and later sends return `409`. Only their START opts them back in.

## Pricing

An RCS agent has a setup fee when it is submitted, a one-time fee on US carrier approval, and a monthly fee from the day it goes live (never while it is in review). Messages are billed per message, sent and received, by destination. See [Pricing](https://pozy.ai/docs/billing) for the current amounts.

## Errors you may see

| Status | Code | Why | What to do |
| --- | --- | --- | --- |
| `403` | | Before launch, only test phones that accepted the invite can receive | Invite that phone as a tester, or wait until the agent is `live` |
| `403` | | Your plan has no inbox | Upgrade to a plan that includes it |
| `409` | `recipient_opted_out` | The person texted STOP | Only their START lets you message them again |
| `409` | `invalid_resource_state` | Carriers have not created the agent yet | Retry once it reaches `testing` or `live` |
| `409` | | A reused brand was rejected, or the change is no longer allowed | Send a new `brand`. Once filed, only `smsFallbackFrom` can change |
| `404` | | The agent is not in your workspace | Check `agentId` |
| `422` | `USAGE_BILLING_REQUIRED` | Billing is not usage-based | Turn on usage-based billing |
| `402` | | No card on file | Add a card |

[Errors](https://pozy.ai/docs/getting-started/errors) covers the error format.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/rcs/brands`](https://pozy.ai/docs/api/listRcsBrands) | List RCS brands |
| [`GET /v1/rcs/agents`](https://pozy.ai/docs/api/listRcsAgents) | List RCS agents |
| [`POST /v1/rcs/agents`](https://pozy.ai/docs/api/createRcsAgent) | Request an RCS agent |
| [`GET /v1/rcs/agents/{agentId}`](https://pozy.ai/docs/api/getRcsAgent) | Get an RCS agent |
| [`PATCH /v1/rcs/agents/{agentId}`](https://pozy.ai/docs/api/updateRcsAgent) | Update an RCS agent |
| [`DELETE /v1/rcs/agents/{agentId}`](https://pozy.ai/docs/api/deactivateRcsAgent) | Deactivate an RCS agent |
| [`POST /v1/rcs/agents/{agentId}/launch-request`](https://pozy.ai/docs/api/requestRcsAgentLaunch) | Send the launch filing |
| [`GET /v1/rcs/agents/{agentId}/test-devices`](https://pozy.ai/docs/api/listRcsTestDevices) | List RCS test phones |
| [`POST /v1/rcs/agents/{agentId}/test-devices`](https://pozy.ai/docs/api/addRcsTestDevice) | Invite an RCS test phone |
| [`DELETE /v1/rcs/agents/{agentId}/test-devices/{testDeviceId}`](https://pozy.ai/docs/api/removeRcsTestDevice) | Remove an RCS test phone |
| [`POST /v1/rcs/assets`](https://pozy.ai/docs/api/uploadRcsAsset) | Upload an RCS logo or banner |
| [`POST /v1/rcs/messages`](https://pozy.ai/docs/api/sendRcsMessage) | Send an RCS message |
| [`GET /v1/rcs/capabilities`](https://pozy.ai/docs/api/getRcsCapabilities) | Check RCS capability |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. |
| [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. |
| [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. |
| [`rcs.agent.status_updated`](https://pozy.ai/docs/webhooks/events#rcs-agent-status-updated) | RCS agent status updated. |

---



# Shopify

Connect a Shopify store to manage its catalog, read its orders, publish blog articles and install ad pixels on the storefront.

Connecting Shopify gives you a store account, which is a different kind of account from a social one. Through it you run the catalog (products, stock, collections, discounts), read orders, publish blog articles and install ad pixels on the storefront. You cannot pick a store as a destination in `POST /v1/posts`, and it has no inbox or social analytics.

## What you can do

| Job | How | Page |
| --- | --- | --- |
| Manage products, variants, images and stock | Commerce API (`/v1/commerce/...`) | [Products](https://pozy.ai/docs/commerce/products) |
| Read orders with line items, tax, refunds and attribution | Commerce API, read-only | [Orders](https://pozy.ai/docs/commerce/orders) |
| Collections, discounts, pages, menus, redirects, markets, metafields | Commerce API | [Commerce overview](https://pozy.ai/docs/commerce) |
| Keep a Meta product catalog in sync for catalog ads | Catalog syncs | [Products](https://pozy.ai/docs/commerce/products#sync-a-store-into-a-meta-catalog) |
| Create, schedule and edit blog articles | Blogs API | [Blogs](https://pozy.ai/docs/commerce/blogs) |
| Fire Meta, TikTok, Google, LinkedIn, Pinterest, X and ChatGPT pixels | Tracking tag install | [Site pixels](https://pozy.ai/docs/commerce/site-pixels) |

| Property | Value |
| --- | --- |
| Platform id | `shopify` |
| Connect | OAuth install (needs the store's `myshopify.com` domain) or a custom-app Admin token |
| Social posting | No |
| Analytics | No (order data comes from the Commerce API) |
| Ids | Shopify's own numeric ids as strings. Read them from responses, never build them |

## Connect a store

Shopify cannot look up a merchant's stores for you, so you need the store domain first. Ask the merchant for it (`acme.myshopify.com`, or just `acme`).

```bash cURL
curl "https://api.pozy.ai/v1/connect/shopify?profileId=6710c3a9e4b0d2f1a8c95e21&shop=acme.myshopify.com&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/shopify?profileId=6710c3a9e4b0d2f1a8c95e21&shop=acme.myshopify.com&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Redirect the merchant to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/shopify",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={
        "profileId": "6710c3a9e4b0d2f1a8c95e21",
        "shop": "acme.myshopify.com",
        "redirect_url": "https://app.example.com/connected",
    },
)
auth_url = resp.json()["authUrl"]
```

Open `authUrl` in the merchant's browser. When they approve the install, Shopify and Pozy send them to your `redirect_url`, whose query string holds `connected`, `profileId`, `accountId` and `username`, the store's domain. Keep the `accountId`. Rely on the redirect rather than on the domain you sent: the merchant can end up approving another store.

`redirect_url` has to be a full `http(s)` URL or a custom app scheme like `myapp://connected`; a relative path gets `400`. If you connect a store again to the profile it is already in, Pozy just renews the existing account's token.

### Connect with an Admin token instead

Without a browser step, the merchant sets up a custom app in the Shopify admin under **Settings > Apps and sales channels > Develop apps**. They give it `read_content`, `write_content`, `read_products` and `write_products`, then hand you its Admin API token, which begins with `shpat_`.

```bash cURL
curl -X POST https://api.pozy.ai/v1/connect/shopify/token \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "6710c3a9e4b0d2f1a8c95e21",
    "shop": "acme.myshopify.com",
    "accessToken": "shpat_..."
  }'
```

Pozy checks the token against the store before it saves anything. Custom-app tokens do not expire. Two things work differently with a token: the store cannot grant optional permissions through Pozy (the merchant adds scopes to the custom app instead), and pixels cannot be installed, because a Shopify web pixel belongs to the app that installs it.

## Permissions

The install asks for the scopes Pozy needs for content and catalog work. Nothing about customers or payments is requested up front.

| Scope | Enables |
| --- | --- |
| `read_content`, `write_content` | Blogs, articles and online store pages |
| `read_products`, `write_products` | Products, variants, images, collections and metafields |
| `write_pixels`, `read_customer_events` | Installing pixels and letting them receive storefront events |

Stock, sales channels, discounts, navigation, metaobjects, markets, marketing activities, image removal and orders need optional scopes. The merchant approves those later without reinstalling. Call `GET /v1/commerce/store`: it lists what the store can do now in `capabilities`, what is missing in `missingCapabilities`, and returns a `grantPermissionsUrl` to send the store owner to.

| Capability | Shopify scope behind it |
| --- | --- |
| `products.images_remove` | `write_files` |
| `inventory.read`, `inventory.write` | `write_inventory` and `read_locations` |
| `channels.read`, `channels.write` | `write_publications` |
| `discounts.read`, `discounts.write`, `discounts.codes` | `write_discounts` |
| `navigation.read`, `navigation.write` | `write_online_store_navigation` |
| `metaobjects.read`, `metaobjects.write` | `write_metaobjects` |
| `markets.read`, `markets.write` | `write_markets` (writes also use `write_products`) |
| `marketing.write` | `write_marketing_events` |
| `orders.read` | `read_orders` and `read_all_orders` |

> **Orders need two scopes:**
> 
> Without `read_all_orders`, Shopify only returns the last 60 days of orders, so a store that granted only `read_orders` still lists `orders.read` as missing. Order reads also need Shopify's approval for protected customer data. Where that approval does not cover buyer details, `customer`, `billingAddress` and `shippingAddress` come back as `null`.

## Shopify-only operations

Most of the Commerce API works on both Shopify and WooCommerce. These operations exist only on Shopify and return `400 platform_not_supported` elsewhere:

- Sales channels: list channels, publish or unpublish products and collections
- Markets and price lists, including fixed prices per market
- Metaobjects and their definitions
- Navigation menus and URL redirects
- Collection metafields and reordering products inside a collection
- Adding codes to an existing discount
- Marketing activities: log a post, ad or message you ran for the merchant so it shows in the store's Marketing area with its UTM tags, then add engagement numbers to it each day

```bash cURL
curl -X PUT https://api.pozy.ai/v1/commerce/marketing-activities \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "6710c4b2e4b0d2f1a8c95e3a",
    "remoteId": "post_6711a0c3e4b0d2f1a8c96001",
    "title": "Autumn drop on Instagram",
    "url": "https://acme.com/collections/autumn",
    "utm": { "campaign": "autumn-drop", "source": "instagram", "medium": "social" },
    "tactic": "post",
    "channel": "social",
    "status": "active"
  }'
```

Set `remoteId` to an id from your side, such as the Pozy post id. A second call with that `remoteId` changes the existing activity instead of adding one.

## Limits and behaviour

- Pozy keeps no copy of store data. Every read goes to the store, except the catalog syncs you create.
- Shopify tells Pozy about product changes through its webhooks, so synced catalogs catch up within minutes and you get `commerce.product.*` events.
- When the merchant uninstalls the app, Shopify revokes the token at once and Pozy marks the account disconnected. There is nothing for you to revoke. Reinstalling runs the normal connect flow and issues a fresh token.
- Blog and article deletes are permanent. Deleting a blog deletes every article in it.
- You cannot create orders, write customers, upload media or publish social posts through a store account.

## Errors

| Error | Cause | Fix |
| --- | --- | --- |
| `400` on connect | `shop` is not a `myshopify.com` domain, or `redirect_url` is relative | Pass `acme.myshopify.com` (or `acme`) and an absolute redirect |
| `403 insufficient_permissions` | The store never granted the needed scope, or Shopify turned the call down | Point the owner to `grantPermissionsUrl`, or connect the store again |
| `409 reconnect_required` | A pixel install on a store connected with a token or without pixel access | Send the merchant to `details.authUrl`. The account keeps its id |
| `404 product_not_found` | The product was deleted or belongs to another store | List products for a current id |
| `404 blog_article_not_found` | The article was deleted or is in another blog | List the blog's articles |
| `429` | Your Pozy rate limit or Shopify's own limit | Wait for `Retry-After`, then retry |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/shopify`](https://pozy.ai/docs/api/getShopifyConnectUrl) | Get Shopify OAuth connect URL |
| [`POST /v1/connect/shopify/token`](https://pozy.ai/docs/api/connectShopifyWithToken) | Connect a Shopify store with a custom-app Admin token |
| [`GET /v1/commerce/store`](https://pozy.ai/docs/api/getCommerceStore) | Get a store |

The full catalog, order and discount surface is listed on [Commerce](https://pozy.ai/docs/commerce#endpoints), and the article endpoints on [Blogs](https://pozy.ai/docs/commerce/blogs#endpoints).

## Webhook events

| Event | When it fires |
| --- | --- |
| [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. |
| [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. |
| [`commerce.product.created`](https://pozy.ai/docs/webhooks/events#commerce-product-created) | Commerce product created. |
| [`commerce.product.updated`](https://pozy.ai/docs/webhooks/events#commerce-product-updated) | Commerce product updated. |
| [`commerce.product.deleted`](https://pozy.ai/docs/webhooks/events#commerce-product-deleted) | Commerce product deleted. |
| [`commerce.order.created`](https://pozy.ai/docs/webhooks/events#commerce-order-created) | Commerce order created. |
| [`commerce.order.updated`](https://pozy.ai/docs/webhooks/events#commerce-order-updated) | Commerce order updated. |
| [`commerce.order.refunded`](https://pozy.ai/docs/webhooks/events#commerce-order-refunded) | Commerce order refunded. |

---



# WordPress

Connect a WordPress.com or self-hosted WordPress site to publish and schedule articles, run its WooCommerce store and install ad pixels.

A connected WordPress site is a blog account. You use it to create, schedule, update and delete the site's posts through the [Blogs API](https://pozy.ai/docs/commerce/blogs). A self-hosted site that runs WooCommerce is also a store, so the same account works with the [Commerce API](https://pozy.ai/docs/commerce). WordPress accounts are never targets of `POST /v1/posts` and have no inbox or social analytics.

## What you can do

| Job | WordPress.com | Self-hosted |
| --- | --- | --- |
| Publish, schedule, edit and delete articles | Yes | Yes |
| Featured images, tags and authors | Yes | Yes |
| WooCommerce products, stock, coupons and orders | No | Yes, when WooCommerce is installed |
| WordPress pages (Commerce pages endpoints) | Yes | Yes |
| Ad pixels as theme widgets | Plans with plugin support only | Yes |

| Property | Value |
| --- | --- |
| Platform id | `wordpress` (store objects report `platform: "woocommerce"`) |
| Connect | WordPress.com: OAuth. Self-hosted: username plus an Application Password |
| Blogs per account | One. Self-hosted sites use blog id `"1"`; WordPress.com uses the site's numeric id |
| Scheduling | Native: WordPress publishes at `publishDate` |
| Article body | Raw HTML in `bodyHtml` |

## Before you start

The WordPress user who connects must be able to publish posts and upload files. Pozy checks both before it saves the account, so a contributor role that can only write drafts is refused. Each account is one site. To manage several sites, connect each one.

## Connect WordPress.com

```bash cURL
curl "https://api.pozy.ai/v1/connect/wordpress?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/wordpress?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Redirect the site owner to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/wordpress",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"},
)
auth_url = resp.json()["authUrl"]
```

The site owner approves access and picks one site on WordPress.com. Pozy asks for single-site access only, never access to every site on the WordPress.com account. Reconnecting the same site on the same profile refreshes the account in place.

When the connection fails, the browser returns to your `redirect_url` with `error` and `platform=wordpress`:

| `error` | Meaning |
| --- | --- |
| `wordpress_access_denied` | The user declined |
| `wordpress_code_rejected` | The authorization expired or was already used. Start again |
| `wordpress_no_site` | No site was picked |
| `wordpress_insufficient_permissions` | The user is not an Author, Editor or Administrator of the site |
| `wordpress_site_unavailable`, `wordpress_unavailable` | The site or WordPress.com did not answer. Try again in a few minutes |
| `wordpress_connection_failed` | Anything else. Start again |

## Connect a self-hosted site

The site owner opens **Users > Profile** in WordPress admin, creates an Application Password, and gives it to you with their login username.

```bash cURL
curl -X POST https://api.pozy.ai/v1/connect/wordpress/token \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "6710c3a9e4b0d2f1a8c95e21",
    "siteUrl": "https://blog.example.com",
    "username": "editor@example.com",
    "applicationPassword": "abcd efgh ijkl mnop qrst uvwx"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/wordpress/token", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    profileId: "6710c3a9e4b0d2f1a8c95e21",
    siteUrl: "https://blog.example.com",
    username: "editor@example.com",
    applicationPassword: "abcd efgh ijkl mnop qrst uvwx",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.account._id);
```
```python Python
resp = requests.post(
    "https://api.pozy.ai/v1/connect/wordpress/token",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "profileId": "6710c3a9e4b0d2f1a8c95e21",
        "siteUrl": "https://blog.example.com",
        "username": "editor@example.com",
        "applicationPassword": "abcd efgh ijkl mnop qrst uvwx",
    },
)
account_id = resp.json()["account"]["_id"]
```

`siteUrl` must be the public HTTPS root of the site. Leave out `/wp-json`, paths and any credentials. Before saving, Pozy finds the site's REST API, refuses private or local network addresses, checks the credentials and confirms the user can publish and upload.

> **Application Passwords are secrets:**
> 
> Store them like any other credential. Revoking the password in WordPress admin cuts access at once. Reconnect with a new password to restore it; the account keeps its id.

## Publish an article

List the blog first and use `blogs[0].id`. That way the same code works for self-hosted (`"1"`) and WordPress.com (numeric site id) accounts.

```bash cURL
curl -X POST https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs/1/articles \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Five things we learned shipping our mobile app",
    "bodyHtml": "

We spent six months on it. Here is what surprised us.

", "excerpt": "Lessons from six months of mobile work.", "tags": ["engineering", "mobile"], "author": "7", "image": { "url": "https://cdn.example.com/mobile-launch.jpg", "altText": "Phone showing the app home screen" }, "publishDate": "2026-11-12T08:00:00Z" }' ``` What WordPress does with each field: | Field | WordPress behaviour | | --- | --- | | `isPublished` | New articles default to draft. `true` publishes now. `false` always wins, even with a future date, so leave it out when you schedule | | `publishDate` | A future date schedules the post natively (`status: "future"`). The site's own cron publishes it | | `tags` | Tag names, up to 100. Existing tags match by name, case-insensitive. Missing tags are created if the user may create terms | | `author` | The numeric WordPress user id as a string, not a login or email | | `image` | Downloaded into the media library and set as the featured image. Public JPEG, PNG, WebP or GIF up to 10 MB | | `seo` | Rejected with `400`. Core WordPress has no portable SEO fields | Responses include WordPress's own `status` (`publish`, `future`, `draft`, `pending` or `private`). `isPublished` is `true` only for `publish`. On update, leaving out `image` keeps the current featured image, and there is no way to remove one through the API. ## WooCommerce When a self-hosted site runs WooCommerce and the connected user can manage it, `GET /v1/commerce/store?accountId=...` lists store capabilities and the account works with the [Commerce API](https://pozy.ai/docs/commerce). WordPress.com sites cannot reach WooCommerce through Pozy. | Area | On WooCommerce | | --- | --- | | Not available | Sales channels, menus, redirects, metaobjects, markets, price lists, marketing activities, discount codes, reordering collection products (`400 platform_not_supported`) | | Products | `vendor`, `productType` and `seo` are rejected. `deactivate` sets the product to draft, `archive` sets it to private | | Collections | Product categories, with no SEO fields or sort order | | Discounts | Coupons only. A coupon cannot start in the future or require a minimum quantity | | Stock | One location with the id `default`. `adjust` reads then writes, so a sale in between can be overwritten | | Metafields | Product custom fields only | | Orders | Read-only, up to 100 per page, no extra permission needed | | Optional permissions | None. `grantPermissionsUrl` is always `null` | Pozy registers order and product webhooks on the site when you connect it, so you get `commerce.order.*` and `commerce.product.*` events. Order attribution needs WooCommerce 8.5 or later with Order Attribution turned on, and records only the last visit. ## Limits - No social posts, comments, DMs, analytics or inbox on a WordPress account. - You cannot create, rename or delete the site's blog (`405`). - Custom post types and categories are not managed through the Blogs API. Pages are available through the Commerce pages endpoints. - Deletes are permanent. Pozy keeps no copy. WordPress keeps the post's media and tags for other content. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400` on self-hosted connect | `siteUrl` is not HTTPS, contains credentials, points at a private address or has no usable REST API | Pass the public HTTPS site root | | `401` or `403` on connect | Wrong or revoked Application Password, or the user cannot publish and upload | Create a new password for a user with publish and upload rights | | `400` on an article | Invalid field, non-numeric `author`, or `seo` was sent | Fix the field named in `param` | | `404 blog_not_found` | Wrong `blogId` | Call List blogs and use the id it returns | | `422 tracking_tag_install_blocked` | The site cannot run pixel scripts. `details.reason` says why | See [Site pixels](https://pozy.ai/docs/commerce/site-pixels#wordpress) | | `502`, `503` | The site failed or timed out | Check the article state before retrying a write; a timed-out write may have landed | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/wordpress`](https://pozy.ai/docs/api/getWordPressAuthUrl) | Get WordPress.com OAuth connect URL | | [`POST /v1/connect/wordpress/token`](https://pozy.ai/docs/api/connectWordPressWithApplicationPassword) | Connect self-hosted WordPress with an application password | | [`GET /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/listBlogs) | List blogs | | [`GET /v1/accounts/{accountId}/blogs/{blogId}`](https://pozy.ai/docs/api/getBlog) | Get a blog | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/listBlogArticles) | List blog articles | | [`POST /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/createBlogArticle) | Create a blog article | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/getBlogArticle) | Get a blog article | | [`PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/updateBlogArticle) | Update a blog article | | [`DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/deleteBlogArticle) | Delete a blog article | ## Webhook events | Event | When it fires | | --- | --- | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`commerce.product.created`](https://pozy.ai/docs/webhooks/events#commerce-product-created) | Commerce product created. | | [`commerce.product.updated`](https://pozy.ai/docs/webhooks/events#commerce-product-updated) | Commerce product updated. | | [`commerce.product.deleted`](https://pozy.ai/docs/webhooks/events#commerce-product-deleted) | Commerce product deleted. | | [`commerce.order.created`](https://pozy.ai/docs/webhooks/events#commerce-order-created) | Commerce order created. | | [`commerce.order.updated`](https://pozy.ai/docs/webhooks/events#commerce-order-updated) | Commerce order updated. | | [`commerce.order.refunded`](https://pozy.ai/docs/webhooks/events#commerce-order-refunded) | Commerce order refunded. | --- # Wix Connect a Wix site through Wix's app installer to publish, schedule and edit its blog posts and to add ad pixels without touching the site's code. A connected Wix site is a blog account with one blog. You create, schedule, edit and delete its Wix Blog posts with the same [Blogs API](https://pozy.ai/docs/commerce/blogs) calls you use for Shopify and WordPress, and you can install ad pixels on the site. Wix accounts are not social accounts: they never appear in `POST /v1/posts`, and they have no inbox or analytics. ## What you can do | Job | Supported | | --- | --- | | Publish, schedule, edit and delete blog posts | Yes | | Cover image, tags, excerpt, SEO title and description | Yes | | Ad pixels (page views) | Yes, through the installed app | | Categories, translations, Wix Stores products | No | | Create, rename or delete the blog | No (`405`) | | Property | Value | | --- | --- | | Platform id | `wix` | | Connect | Approve the app on the site in Wix's installer | | Blog id | Always `"1"` | | Article ids | Wix GUIDs | | Scheduling | Native: Wix publishes at `publishDate` | ## Before you start The site needs the **Wix Blog** app, and the person connecting must be allowed to install apps on the site. ## Connect a site ```bash cURL curl "https://api.pozy.ai/v1/connect/wix?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/wix?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Redirect the site owner to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/wix", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"}, ) auth_url = resp.json()["authUrl"] ``` The user picks a site in Wix's installer and approves the app. Wix sends them back, Pozy saves the account and redirects to your `redirect_url`. Connecting the same site to the same profile again refreshes the account. Access renews on its own. Removing the app from the site in Wix disconnects the account. ## Publish a post ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6710c4c8e4b0d2f1a8c95e44/blogs/1/articles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Our winter opening hours", "bodyHtml": "

From 1 December

We open at 10:00 on weekdays.

", "handle": "winter-hours", "excerpt": "New opening hours from 1 December.", "tags": ["news", "hours"], "image": { "url": "https://cdn.example.com/storefront-winter.jpg", "altText": "Shop front with snow" }, "seo": { "title": "Winter opening hours", "description": "Our new hours from 1 December." }, "publishDate": "2026-11-28T09:00:00Z" }' ``` How Wix treats each field: | Field | Behaviour | | --- | --- | | `bodyHtml` | Converted to Wix rich content. Each top-level HTML element must stay under 30,000 characters. Images in the body are imported into the site's Media Manager | | `title`, `excerpt`, `handle` | Up to 200, 500 and 100 characters | | `tags` | Matched by name, created when missing, up to 30 per post | | `author` | A Wix site member id. Without it Pozy uses the author of the site's latest post, or creates an author named after the site | | `image` | Cover image, imported into the Media Manager. JPEG, PNG, WebP or GIF up to 10 MB, at most three redirects | | `isPublished` | `false` keeps a draft and wins over a date. `true` publishes now | | `publishDate` | A future date schedules the post in Wix itself, so it goes out even if your servers are down | Edits to a published post go live straight away. Lists return `bodyHtml: null`, so get the article to read its body. `url` is the public address while the post is published, and `null` for drafts. Deletes are permanent and skip the Wix trash. ## Pixels The installed app adds one script to the site that loads each installed tag's base code, so every tag records page views. Wix swaps pages without a reload, and Pozy reports those page views again for Meta, TikTok, Google, Pinterest and ChatGPT. LinkedIn and X count the first page of each visit. The script is registered with Wix as an advertising script, so on sites with Wix's cookie banner it runs only for visitors who accept advertising cookies. See [Site pixels](https://pozy.ai/docs/commerce/site-pixels). ## Errors | Error | Cause | Fix | | --- | --- | --- | | `wix_install_failed` on the redirect | The installer did not return a valid install | Start again from a fresh `authUrl` | | `wix_connection_failed` on the redirect | The site could not be read, often because Wix Blog is missing | Add Wix Blog and connect again | | `400` on an article | A field is too long, more than 30 tags, `author` is not a member id, or an HTML element is over 30,000 characters | Fix the field or split the HTML | | `400` on an image | The URL is unreachable, redirects too often, or the file is not a supported image | Use a direct public image URL | | `403` | The app was removed from the site | Reconnect the site | | `409 reconnect_required` on a pixel install | The site has not approved the permission to add scripts | Send the owner to `details.authUrl`, or to **Apps > Manage Apps** in Wix, then retry | | `429` | Wix rate limited the request | Retry after `Retry-After` | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/wix`](https://pozy.ai/docs/api/getWixConnectUrl) | Get Wix site install URL | | [`GET /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/listBlogs) | List blogs | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/listBlogArticles) | List blog articles | | [`POST /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/createBlogArticle) | Create a blog article | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/getBlogArticle) | Get a blog article | | [`PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/updateBlogArticle) | Update a blog article | | [`DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/deleteBlogArticle) | Delete a blog article | --- # Webflow Connect a Webflow site to publish and edit items in its CMS blog collections and to add ad pixels to the site's custom code. A connected Webflow site is a blog account. Every CMS collection on the site that has a rich text field shows up as a blog, and its items are articles you create, publish, edit and delete through the [Blogs API](https://pozy.ai/docs/commerce/blogs). Webflow accounts are not social accounts and have no inbox or analytics. ## What you can do | Job | Supported | | --- | --- | | Create, publish, edit, unpublish and delete CMS items | Yes | | Fill your collection's own fields (`fieldData`) | Yes | | Schedule an item for later | No. Webflow's API cannot schedule | | SEO fields, localized items, Ecommerce products | No | | Create, rename or delete collections | No (`405`) | | Ad pixels | Yes, with the OAuth connection only | | Property | Value | | --- | --- | | Platform id | `webflow` | | Connect | OAuth with the Pozy Webflow app, or a site API token | | Blog id | The collection id | | Status | `publish` or `draft` | ## Before you start You need a site on a CMS plan with a blog-style collection. The site must have been published at least once before items can go live. ## Connect with Webflow (OAuth) Use this path when you also want to install pixels. There is nothing to copy. ```bash cURL curl "https://api.pozy.ai/v1/connect/webflow?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/webflow?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/webflow", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"}, ) ``` The user picks exactly one site on Webflow's screen. Picking several connects none and returns `error=webflow_pick_one_site`. Connecting a site that was first connected with a token upgrades the same account. ## Connect with a site API token Create the token inside Webflow: go to **Site settings > Apps & integrations > API access**, then generate one with **Sites** set to read-only and **CMS** set to read and write. Each site token works for a single site. ```bash cURL curl -X POST https://api.pozy.ai/v1/connect/webflow/token \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6710c3a9e4b0d2f1a8c95e21", "siteToken": "wf_site_token_..." }' ``` Pozy confirms the token's permissions, looks up the site it belongs to, and saves it encrypted. Webflow cancels site tokens that go unused for 365 days. When a token dies, because someone deleted or regenerated it, the account is marked as needing a reconnect; connect it again with a fresh token. ## Publish an item Find the collection with `GET /v1/accounts/{accountId}/blogs`, then create the item: ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6710c4d1e4b0d2f1a8c95e4f/blogs/6512f0a8c1d2e3f4a5b6c7d8/articles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "How we cut page load time in half", "bodyHtml": "

Three changes did most of the work.

", "excerpt": "Three changes that halved our load time.", "tags": ["performance"], "image": { "url": "https://cdn.example.com/speed.png", "altText": "Load time chart" }, "fieldData": { "reading-time": "4 min" }, "isPublished": true }' ``` Pozy reads the collection's fields and maps article fields by type and slug: | Article field | Webflow field it fills | | --- | --- | | `title`, `handle` | The item's name and slug. No `handle`? The slug is made from the title, plus a suffix when it is already used | | `bodyHtml` | The collection's rich text field, choosing one called body, content or post when there are several | | `excerpt` | A plain text field whose slug is summary, excerpt, description or intro | | `image` | An image field, choosing one called main, cover, featured, hero, header or thumbnail when it exists | | `tags` | A multi-reference field named tags, categories or topics. Missing tags are created and published | | `author` | A reference field called author or writer. Pass the id of the item it points to | | `publishDate` | A date field named publish or date | Any other field of the collection goes in `fieldData`, keyed by its slug and written the way Webflow expects: an item id for a reference, `{ "url": "...", "alt": "..." }` for an image. If a create would leave required fields empty, you get `400` naming all of them. You also get `400` for a slug the collection lacks, or one Pozy fills from the article fields above. ### Publishing rules - `isPublished: true` publishes the item to the live site now. Leaving it out, or `false`, keeps a draft. - `isPublished: false` on a published item unpublishes it. Edits to a published item go live. - `publishDate` cannot be in the future (`400`). On a draft (`isPublished: false`) it is simply stored in the date field. - `seo` returns `400`. Code blocks in the HTML are dropped by Webflow. Body images are copied to Webflow's asset hosting. - Delete unpublishes the item from the live site, then deletes it. ## Pixels Installing a pixel needs the OAuth connection, not a site token. Webflow only ships custom code when the whole site is published, so the tag starts firing after your next publish, and the install response always says `publishRequired: true`. Pozy leaves publishing to you: publishing the site would also release every pending change in the Designer. See [Site pixels](https://pozy.ai/docs/commerce/site-pixels). ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400` "rejected the site token" | Wrong, deleted or regenerated token | Generate a new site token | | `400` "missing cms:write" | The token cannot write to the CMS | Generate one with CMS set to read and write (Sites can stay read-only) | | `400` "scoped to one site" | You sent a workspace token, or a token for more than one site | Switch to a site API token | | `400` "Publish the Webflow site once" | The site was never published | Publish it once in Webflow, then retry | | `403` | Webflow does not accept the token | Connect again with a fresh token | | `404 blog_not_found` | No collection with a rich text field has that id | Check the ids in the account's blog list | | `429` | Webflow caps each token at 60 to 120 requests per minute, depending on the site's plan | Wait for `Retry-After`, then retry | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/webflow`](https://pozy.ai/docs/api/getWebflowConnectUrl) | Get Webflow OAuth URL | | [`POST /v1/connect/webflow/token`](https://pozy.ai/docs/api/connectWebflowWithSiteToken) | Connect Webflow with a site API token | | [`GET /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/listBlogs) | List blogs | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/listBlogArticles) | List blog articles | | [`POST /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/createBlogArticle) | Create a blog article | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/getBlogArticle) | Get a blog article | | [`PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/updateBlogArticle) | Update a blog article | | [`DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/deleteBlogArticle) | Delete a blog article | --- # HubSpot Connect a HubSpot portal so inbox contacts become HubSpot contacts, messages land on their timelines and closed-won deals reach your ad platforms. Link a HubSpot portal to a profile, and the profile's [inbox](https://pozy.ai/docs/inbox) flows into your CRM by itself. Anyone who writes to you is created as a HubSpot contact, each message lands on that contact's timeline, and won deals can go back to your ad networks as purchases. You make no calls after connecting: each message syncs a few seconds after it arrives. ## What you can do | Job | How | | --- | --- | | Create or match HubSpot contacts for inbox contacts | Automatic (`syncContacts`, on by default) | | Log every inbox message on the contact's timeline | Automatic (`logMessages`, on by default) | | Write a contact at a chosen step of a flow | `hubspot_upsert_contact` workflow node | | Send closed-won deals to ad platforms as purchases | `reportDeals`, off by default | | Property | Value | | --- | --- | | Platform id | `hubspot` | | How it connects | OAuth, through HubSpot's own consent screen, which installs the app in the portal | | Scopes | Always `oauth`, `crm.objects.contacts.read` and `crm.objects.contacts.write`; `crm.objects.deals.read` when you want deals | | Portals per profile | A single one | | Posting, analytics, inbox on the account itself | No | ## Connect a portal Whoever connects needs a HubSpot login with permission to install apps in that portal. ```bash cURL curl "https://api.pozy.ai/v1/connect/hubspot?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/hubspot?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Redirect the user to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/hubspot", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"}, ) auth_url = resp.json()["authUrl"] ``` The user picks a portal and approves the app. They return to your `redirect_url` with `connected=hubspot`, `profileId`, `accountId` and `username` (the portal domain). Contact sync and message logging start right away. Reconnecting the same portal refreshes the account and keeps its settings. ## Choose what syncs ```bash cURL curl -X PATCH https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "logMessages": false, "reportDeals": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ logMessages: false, reportDeals: true }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.account); ``` ```python Python resp = requests.patch( "https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"logMessages": False, "reportDeals": True}, ) ``` | Setting | Default | What it does | | --- | --- | --- | | `syncContacts` | `true` | Creates a HubSpot contact when an inbox contact matches none | | `logMessages` | `true` | Writes each inbox message on the contact's timeline | | `reportDeals` | `false` | Sends deals that become closed won as `Purchase` events | Read the current values, plus the portal id and granted scopes, with `GET /v1/accounts/{accountId}/hubspot-settings`. ## How contacts are matched For each message, Pozy looks for the HubSpot contact in this order: 1. A contact with the same email address. 2. A contact with the same phone number, taken from the person's WhatsApp, SMS, RCS or iMessage channel. 3. With `syncContacts` on, a new contact with the person's name, email and phone. With it off, the message is skipped. Once found, the link is kept. Pozy attaches to a contact that already exists without changing its name or any property. Should that contact later be deleted or merged, Pozy looks for a match again. ## How messages are logged | Channel | Logged as | | --- | --- | | WhatsApp | Communication (WhatsApp) | | SMS | Communication (SMS) | | Any other inbox channel | Note | Each entry names the channel and direction, then the text. Attachments are listed as links. A message is logged once, even when its sync is retried. Only conversations linked to an inbox contact sync, and only messages sent or received after you connected. Nothing is backfilled. ## Save a contact from a workflow To push a lead to HubSpot at a chosen step, say right after a bot collects an email address, put a `hubspot_upsert_contact` node in the [workflow](https://pozy.ai/docs/automations/workflows): ```json { "id": "save-lead", "type": "hubspot_upsert_contact", "config": { "email": "{{answer}}", "properties": { "lifecyclestage": "lead" } } } ``` The node finds the contact with the same matching as the inbox sync, then writes just the fields you filled in; empty values are ignored. Keys in `properties` are HubSpot's internal property names. Afterwards the flow continues on `success`, or on `error` if the workflow's profile has no HubSpot portal or HubSpot rejects the update. ## Report closed-won deals With `reportDeals` on, every deal that becomes closed won is sent as a `Purchase` to the profile's event destinations, so Meta, Google and your other ad platforms see real revenue. See [Conversions](https://pozy.ai/docs/ads/conversions). | Purchase field | Taken from | | --- | --- | | `value`, `currency` | The deal's amount and currency, falling back to the portal's default currency | | `eventTime` | When the deal closed | | `eventId` | `hubspot_deal_{portalId}_{dealId}`, which stops a network from counting one deal twice | | `user` | Email, phone, name and address of the first contact on the deal | Deals are checked about once an hour. Deals closed before you turned the setting on are not sent, and each deal is sent once. The portal must have granted deal access: if the deals option shows as locked on the consent screen, a HubSpot admin enables it under **Settings > Integrations > Connected Apps**, then you reconnect. ## Disconnect `DELETE /v1/accounts/{accountId}` stops syncing and uninstalls the app from the portal, unless another profile still uses that portal. Uninstalling from HubSpot shows the account as disconnected the next time a message syncs. Contacts and timeline entries already written stay in HubSpot. ## Limits - One portal per profile. - Contacts and their timelines only. Companies, deals and tickets are not synced (deals are read only for purchase reporting). - Some channels log only messages sent from the inbox, not every broadcast, sequence or workflow send. - A busy portal that HubSpot rate limits can lag by a few minutes; Pozy waits and retries. ## Errors | Error | Why | What to do | | --- | --- | --- | | `hubspot_profile_has_portal` on the redirect | A different portal is already linked to this profile | Disconnect that one first, or pick another profile | | `hubspot_access_denied` on the redirect | The user did not approve the app | Start again and approve it | | `hubspot_invalid_state` on the redirect | The connect link was too old or had been used already | Get a new `authUrl` | | `hubspot_connection_failed` on the redirect | HubSpot did not complete the token exchange, or Pozy could not read the portal | Try again; if it keeps failing, write to support@pozy.ai | | `400` on settings | No boolean field in the body, deal access missing for `reportDeals`, or not a HubSpot account | Send at least one setting to a HubSpot account id | | `404` on settings | The account is gone or your key cannot reach it | List accounts for a current id | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/hubspot`](https://pozy.ai/docs/api/getHubSpotConnectUrl) | Get HubSpot OAuth connect URL | | [`GET /v1/accounts/{accountId}/hubspot-settings`](https://pozy.ai/docs/api/getHubSpotSettings) | Get HubSpot account settings | | [`PATCH /v1/accounts/{accountId}/hubspot-settings`](https://pozy.ai/docs/api/updateHubSpotSettings) | Update HubSpot account settings | | [`GET /v1/contacts`](https://pozy.ai/docs/api/listContacts) | List contacts | ## Webhook events | Event | When it fires | | --- | --- | | [`contact.updated`](https://pozy.ai/docs/webhooks/events#contact-updated) | Contact updated. | | [`contact.deleted`](https://pozy.ai/docs/webhooks/events#contact-deleted) | Contact deleted. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`contact.tag_added`](https://pozy.ai/docs/webhooks/events#contact-tag-added) | Contact tag added. | | [`contact.tag_removed`](https://pozy.ai/docs/webhooks/events#contact-tag-removed) | Contact tag removed. | | [`contact.field_changed`](https://pozy.ai/docs/webhooks/events#contact-field-changed) | Contact field changed. | --- # Google Analytics Connect a GA4 property and run reports, realtime reports and dimension lookups against it with your Pozy API key. Connect a Google Analytics 4 property to a profile and read its data with the same key you use for everything else. One account is one GA4 property. Access is read-only: Pozy can run reports but never changes the property, its events or its audiences. Use it to put website traffic next to your social and ads numbers. ## What you can do | Job | Endpoint | | --- | --- | | Run a report (sessions by source, conversions by campaign, page views by path...) | `POST /v1/analytics/googleanalytics/report` | | See activity in the last 30 minutes | `POST /v1/analytics/googleanalytics/realtime` | | List every dimension and metric the property accepts, custom ones included | `GET /v1/analytics/googleanalytics/metadata` | | Property | Value | | --- | --- | | Platform id | `googleanalytics` | | Connect | OAuth with a Google account that has at least Viewer access to a GA4 property | | Scope | `https://www.googleapis.com/auth/analytics.readonly` | | Properties per account | One. Connect another property to a different profile | | List the accounts | `GET /v1/accounts?category=analytics` | | Universal Analytics | Not supported | ## Connect a property ```bash cURL curl "https://api.pozy.ai/v1/connect/googleanalytics?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/googleanalytics?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/googleanalytics", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"}, ) ``` The user signs in with Google and allows read access. If their Google account can read one property, it connects straight away. If it can read several, they pick one on a page hosted by Pozy. Either way they return to your `redirect_url` with `connected=googleanalytics`, `profileId`, `accountId` and `username` (the property name). ### Use your own property picker To keep users in your app, add `headless=true` to your `redirect_url`. Instead of Pozy's picker, the user lands on your URL with `step=select_property`, `profileId`, `platform=googleanalytics` and a `pendingDataToken`. List the properties, show them, then connect the one the user picks: ```bash cURL curl "https://api.pozy.ai/v1/connect/googleanalytics/properties?pendingDataToken=$TOKEN" \ -H "Authorization: Bearer $POZY_API_KEY" curl -X POST https://api.pozy.ai/v1/connect/googleanalytics/select-property \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pendingDataToken": "'"$TOKEN"'", "propertyId": "312456789" }' ``` ```ts Node const listRes = await fetch(`https://api.pozy.ai/v1/connect/googleanalytics/properties?${new URLSearchParams({ pendingDataToken: token })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const list = await listRes.json(); if (!listRes.ok) throw new Error(list.error); const doneRes = await fetch("https://api.pozy.ai/v1/connect/googleanalytics/select-property", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ pendingDataToken: token, propertyId: list.properties[0].propertyId }), }); const done = await doneRes.json(); if (!doneRes.ok) throw new Error(done.error); // Send the user to done.redirect_url ``` ```python Python props = requests.get( "https://api.pozy.ai/v1/connect/googleanalytics/properties", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"pendingDataToken": token}, ).json()["properties"] done = requests.post( "https://api.pozy.ai/v1/connect/googleanalytics/select-property", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"pendingDataToken": token, "propertyId": props[0]["propertyId"]}, ).json() ``` Both calls need your API key (or a connect token) for the same profile; the pending token alone is not enough. It expires after one hour. Listing does not use it up, and a failed select leaves it valid for a retry. More on this pattern in [Headless connect](https://pozy.ai/docs/connect/headless). ## Run a report Ask for 1 to 10 metrics and up to 9 dimensions by their GA4 API names: ```bash cURL curl -X POST https://api.pozy.ai/v1/analytics/googleanalytics/report \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c4f2e4b0d2f1a8c95e6b", "metrics": ["sessions", "conversions"], "dimensions": ["sessionSource"], "dateRanges": [{ "startDate": "28daysAgo", "endDate": "yesterday" }], "orderBys": [{ "metric": { "metricName": "sessions" }, "desc": true }], "limit": 10 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/googleanalytics/report", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6710c4f2e4b0d2f1a8c95e6b", metrics: ["sessions", "conversions"], dimensions: ["sessionSource"], dateRanges: [{ startDate: "28daysAgo", endDate: "yesterday" }], limit: 10, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const row of data.rows) console.log(row.dimensionValues[0].value, Number(row.metricValues[0].value)); ``` ```python Python report = requests.post( "https://api.pozy.ai/v1/analytics/googleanalytics/report", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6710c4f2e4b0d2f1a8c95e6b", "metrics": ["sessions", "conversions"], "dimensions": ["sessionSource"], "dateRanges": [{"startDate": "28daysAgo", "endDate": "yesterday"}], "limit": 10, }, ).json() ``` The response is Google's report with `accountId` and `propertyId` added: `dimensionHeaders`, `metricHeaders`, `rows`, `rowCount`, `metadata` and `propertyQuota`. Every value in `rows` is a string, numbers included. Convert using the `type` in `metricHeaders`. | Field | What it does | | --- | --- | | `metrics` | 1 to 10 metric names. Required | | `dimensions` | Up to 9 dimension names | | `dateRanges` | Up to 4 ranges. Dates are `YYYY-MM-DD`, `today`, `yesterday` or `NdaysAgo`. Default: `28daysAgo` to `yesterday` | | `dimensionFilter`, `metricFilter`, `orderBys` | Google's own filter and sort shapes | | `limit`, `offset` | Up to 10,000 rows per page. Page with `offset` until you reach `rowCount` | | `keepEmptyRows` | Include rows whose metrics are all zero | | `currencyCode` | ISO 4217 code for money metrics. Defaults to the property's currency | The realtime endpoint takes the same metrics, dimensions, filters, `orderBys` and `limit`, plus optional `minuteRanges`, and no date ranges or offset. ## Quotas and caching | Limit | Value | | --- | --- | | Identical report requests | Cached for 10 minutes | | Metadata | Cached for 1 hour | | Realtime reports | Never cached | | Google tokens per property | 200,000 a day and 40,000 an hour | | Tokens per property across all Pozy requests | 14,000 an hour | Bigger date ranges, more dimensions and higher limits cost more tokens. Cached responses cost none. `propertyQuota` in each response shows what is left. Hourly quotas refill within the hour, daily quotas at midnight Pacific time. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `googleanalytics_scope_missing` on the redirect | The Analytics permission was unticked | Connect again and keep it ticked | | `googleanalytics_no_properties` on the redirect | The Google account cannot read any GA4 property | Grant it access in Google Analytics first | | `googleanalytics_access_denied`, `googleanalytics_invalid_state` | Declined, or the link expired | Request a fresh `authUrl` | | `400` on a report | A field or a dimension or metric name is invalid; Google's message names it | Check names with the metadata endpoint | | `403` on a report | The Google account lost access, or removed the app | Reconnect | | `429 rate_limited` | The property's Google quota is used up | Retry after it refills; reuse identical requests to hit the cache | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/googleanalytics`](https://pozy.ai/docs/api/getGoogleAnalyticsConnectUrl) | Get Google Analytics OAuth connect URL | | [`GET /v1/connect/googleanalytics/properties`](https://pozy.ai/docs/api/listGoogleAnalyticsProperties) | List Google Analytics properties for a pending connect | | [`POST /v1/connect/googleanalytics/select-property`](https://pozy.ai/docs/api/selectGoogleAnalyticsProperty) | Connect the chosen Google Analytics property | | [`POST /v1/analytics/googleanalytics/report`](https://pozy.ai/docs/api/runGoogleAnalyticsReport) | Run a Google Analytics (GA4) report | | [`POST /v1/analytics/googleanalytics/realtime`](https://pozy.ai/docs/api/runGoogleAnalyticsRealtimeReport) | Run a Google Analytics (GA4) realtime report | | [`GET /v1/analytics/googleanalytics/metadata`](https://pozy.ai/docs/api/getGoogleAnalyticsMetadata) | List Google Analytics (GA4) dimensions and metrics | --- # Google Workspace Connect a Google account to read and send Gmail, manage Google Calendar events, work with Drive files and sync Google Contacts. A single Google connection gives Pozy access to one person's Gmail, Google Calendar, Google Drive and Google Contacts, as one account per mailbox. At connect time you decide which of those four areas to ask for. Ask for more later and the account id stays the same. The calls themselves are the same for Google and Microsoft mailboxes: see [Email](https://pozy.ai/docs/productivity/email), [Calendar](https://pozy.ai/docs/productivity/calendar) and [Files](https://pozy.ai/docs/productivity/files). ## What you can do | Area | Value in `scopes` | Google permission | What it unlocks | | --- | --- | --- | --- | | Email | `email` | `gmail.modify` | List, search, read, send, reply, label, archive and trash Gmail | | Calendar | `calendar` | `calendar` | Events with Google Meet links, RSVP, free-busy and availability | | Files | `files` | `drive.file` | Only files created through Pozy or picked by the user | | Contacts | `contacts` | `contacts.readonly` | Google Contacts copied into `/v1/contacts` once a day, read-only | On top of those, each connection requests `openid`, `userinfo.email` and `userinfo.profile`, which tell Pozy whose mailbox it is. | Property | Value | | --- | --- | | Platform id | `google` | | Accounts | One per Google account and profile. Reconnecting widens the same account | | Inbox | New mail and API sends appear as inbox conversations, one per Gmail thread | | Posting and analytics | No | | List the accounts | `GET /v1/accounts?platform=google` | ## Connect an account `scopes` is required. Ask only for what your product uses: Google lists every permission on the consent screen, and Workspace admins review apps by what they request. ```bash cURL curl "https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Redirect the user to data.authUrl ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/google", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6710c3a9e4b0d2f1a8c95e21", "scopes": "email,calendar", "redirect_url": "https://app.example.com/connected", }, ) auth_url = resp.json()["authUrl"] ``` The user picks a Google account and approves. They return to your `redirect_url` with `connected=google`, `profileId`, `accountId` and `username` (the mailbox address). From the API there is no Pozy screen in between: your app picks the areas and the user sees only Google. What counts is what Google grants, not what you asked for. A permission the user unchecks on Google's screen leaves its area out, and calls in that area answer `412 account_scope_missing`. If the user unchecks all of them, Pozy saves no account and sends your redirect `error=no_access_granted`. ## Add areas later Connect the same Google account again with the longer list. Google keeps what it granted before and Pozy updates the same account. Pass `reconnectAccountId` to make sure the reconnect lands on that account, and `loginHint` to preselect the mailbox: ```bash cURL curl "https://api.pozy.ai/v1/connect/google?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar,files&reconnectAccountId=6710c501e4b0d2f1a8c95e77&loginHint=ana@example.com" \ -H "Authorization: Bearer $POZY_API_KEY" ``` A reconnect can add areas but never takes one away, even if you leave it out. To remove an area, disconnect the account, then connect it again asking only for the areas you still need. ## Gmail limits | What | Limit | | --- | --- | | Daily sends from a personal Gmail mailbox | 200 | | Daily sends from a Google Workspace mailbox | 500 | | When the daily count restarts | 00:00 UTC | | One body sent to over 50 recipients in an hour | A pause of 10 minutes, twice as long on each repeat up to 24 hours, with an `account.rate_limited` event | | Addresses per field | 100 in `to`, 100 in `cc`, 100 in `bcc` | | Attachments | 20 files, 25 MB combined | A reply uses up a send, and so does a message Gmail refuses. Read your remaining budget from the `X-Pozy-Send-Limit`, `X-Pozy-Send-Remaining` and `X-Pozy-Send-Reset` headers on each send, or from `sendCap` in `GET /v1/accounts/{accountId}/health`. Use the mailbox for personal mail; newsletters belong elsewhere. ## Drive access Pozy connects Drive with `drive.file`, never full Drive access. It sees two kinds of files: files created through Pozy (`POST /v1/files`, `POST /v1/files/folders`) and files the user picked in Google Picker. Everything else stays invisible and returns `404` with `details.reason: "file_not_granted"`. [Picker sessions](https://pozy.ai/docs/productivity/files#ask-the-user-to-choose-drive-files), which let the user grant an existing file, are not available yet, so for now Pozy works with the files it created. Once they open, a pick is a lasting grant until the user removes the app from their Google account. Google's own formats are not stored as files, so Pozy exports them on download: Docs and Slides become PDF, Sheets become XLSX and Drawings become PNG. Forms and Sites cannot be downloaded at all and say `downloadable: false`. Going the other way, `convertTo` on an upload turns the file into a Google format. ## Contacts sync With the `contacts` area, Pozy copies Google Contacts into the profile's [contacts](https://pozy.ai/docs/inbox/contacts) right after connecting, then once a day for changes only. There is no on-demand trigger. List them with `GET /v1/contacts?profileId=...&platform=google`. - People without an email address are skipped. Only the first address is used. - A person already known from another channel (WhatsApp, SMS...) with the same email gets a Google email channel instead of a second contact. - The sync writes name, company, photo and email, and never touches tags, custom fields or other channels. - Nothing is written back to Google. Deleting a contact in Google removes its Google channel and fires `contact.deleted`; the contact itself stays. ## Push notifications When the account connects, Pozy subscribes to Google's push notifications for the granted areas and keeps them renewed (Google channels expire within seven days; Pozy renews any that expire within 24 hours). You only subscribe your own [webhook](https://pozy.ai/docs/webhooks) to the events you want. | Event | Area | Fires when | | --- | --- | --- | | `message.received`, `message.sent`, `conversation.started` | `email` | Mail arrives in the inbox, is sent through the API, or starts a new thread | | `calendar.event.created`, `.updated`, `.deleted` | `calendar` | Someone adds, edits or removes an event in the primary calendar | | `file.created`, `file.updated`, `file.deleted` | `files` | A Drive file Pozy has access to is added, edited or removed | | `file.picked` | `files` | The user is done choosing files in the picker | | `contact.updated`, `contact.deleted` | `contacts` | The daily sync changes a contact (up to a day after the change) | | `account.rate_limited` | `email` | The mailbox enters a sending cooldown | The `push` field of `GET /v1/accounts/{accountId}/health` is `active`; `pending`, which Pozy retries within six hours; `failed`, after three failed renewals, retried once a day; or `not_applicable`. Expect Google to repeat a change now and then, and deduplicate on the object's `id`. When Gmail has lost the history between two notifications, Pozy picks up from the present, so fetch anything you missed with `GET /v1/email/messages`. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400 invalid_field_value`, `param: scopes` | `scopes` missing, empty or unknown | Pass a subset of `email`, `calendar`, `files`, `contacts` | | `error=oauth_denied` on the redirect | The user said no, or Google turned the app down. With `admin_policy_enforced`, a Workspace admin has blocked it | Try the connect again, or have the admin allow the app | | `401 reconnect_required` | Google no longer accepts the account's access | Have the user reconnect | | `412 account_scope_missing` | The account lacks the area. `details` has `grantedAreas` and `missingArea` | Reconnect with the area added | | `429 email_send_cap_reached` | Today's cap is used. `details.cap` has `limit`, `used`, `resetsAt` | Wait for `resetsAt` | | `429 rate_limited` | A sending cooldown, or Google throttled the mailbox | Wait for `Retry-After` | | `502 platform_api_error` | Google returned an unmapped error | Retry. The message carries Google's | If Google will not renew access, for instance because the user removed the app in their Google account settings, Pozy marks the account disconnected and sends `account.disconnected`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | | Endpoint | What it does | | --- | --- | | [`GET /v1/calendar/calendars`](https://pozy.ai/docs/api/listCalendars) | List calendars | | [`GET /v1/calendar/events`](https://pozy.ai/docs/api/listCalendarEvents) | List events in a window | | [`POST /v1/calendar/events`](https://pozy.ai/docs/api/createCalendarEvent) | Create an event | | [`GET /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/getCalendarEvent) | Get an event | | [`PATCH /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/updateCalendarEvent) | Update an event | | [`DELETE /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/deleteCalendarEvent) | Delete an event | | [`POST /v1/calendar/events/{eventId}/rsvp`](https://pozy.ai/docs/api/rsvpCalendarEvent) | Respond to an invitation | | [`POST /v1/calendar/free-busy`](https://pozy.ai/docs/api/calendarFreeBusy) | Busy blocks for one or more accounts | | [`POST /v1/calendar/availability`](https://pozy.ai/docs/api/calendarAvailability) | Find meeting slots across accounts | | [`GET /v1/email/threads`](https://pozy.ai/docs/api/listEmailThreads) | List email threads | | [`GET /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/getEmailThread) | Get an email thread with its messages | | [`PATCH /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/updateEmailThread) | Update every message in a thread | | [`GET /v1/email/messages`](https://pozy.ai/docs/api/listEmailMessages) | List email messages | | [`POST /v1/email/messages`](https://pozy.ai/docs/api/sendEmailMessage) | Send an email | | [`GET /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/getEmailMessage) | Get an email message | | [`PATCH /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/updateEmailMessage) | Mark read or starred, or move a message | | [`DELETE /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/trashEmailMessage) | Move a message to trash | | [`POST /v1/email/messages/{messageId}/reply`](https://pozy.ai/docs/api/replyEmailMessage) | Reply to a message | | [`GET /v1/email/folders`](https://pozy.ai/docs/api/listEmailFolders) | List mailbox folders | | [`GET /v1/email/attachments/{attachmentId}`](https://pozy.ai/docs/api/downloadEmailAttachment) | Download an attachment | | [`GET /v1/files`](https://pozy.ai/docs/api/listFiles) | List files in a folder or search the drive | | [`POST /v1/files`](https://pozy.ai/docs/api/uploadFile) | Upload a file | | [`POST /v1/files/folders`](https://pozy.ai/docs/api/createFolder) | Create a folder | | [`POST /v1/files/picker-sessions`](https://pozy.ai/docs/api/createFilePickerSession) | Create a hosted Google Picker session (not available yet) | | [`GET /v1/files/{fileId}`](https://pozy.ai/docs/api/getFile) | Get a file or folder | | [`PATCH /v1/files/{fileId}`](https://pozy.ai/docs/api/updateFile) | Rename or move a file | | [`DELETE /v1/files/{fileId}`](https://pozy.ai/docs/api/trashFile) | Move a file to the trash | | [`GET /v1/files/{fileId}/download`](https://pozy.ai/docs/api/downloadFile) | Download a file | | [`GET /v1/files/{fileId}/share-link`](https://pozy.ai/docs/api/getFileShareLink) | Get an anyone-with-the-link share link | --- # Microsoft 365 Connect a Microsoft 365 or Outlook.com account to use Outlook mail, Outlook Calendar with Teams meetings, OneDrive files and Outlook contacts. One Microsoft connection gives Pozy a person's Outlook mail, Outlook Calendar, OneDrive and Outlook contacts. It works for Microsoft 365 work or school logins and for personal Outlook.com or Hotmail logins alike. Each account stands for one mailbox, and you pick the areas it covers when you connect. The calls are shared with Google mailboxes: see [Email](https://pozy.ai/docs/productivity/email), [Calendar](https://pozy.ai/docs/productivity/calendar) and [Files](https://pozy.ai/docs/productivity/files). ## What you can do | Area | Value in `scopes` | Microsoft Graph permissions | What it unlocks | | --- | --- | --- | --- | | Email | `email` | `Mail.ReadWrite`, `Mail.Send` | List, search, read, send, reply, flag, move and trash Outlook mail | | Calendar | `calendar` | `Calendars.ReadWrite` | Events with Teams meetings, RSVP, free-busy and availability | | Files | `files` | `Files.ReadWrite` | Every OneDrive file the user can reach, no picker needed | | Contacts | `contacts` | `Contacts.Read` | Outlook contacts copied into `/v1/contacts` once a day, read-only | Each connection adds `openid`, `profile`, `email`, `offline_access` and `User.Read` as well: they identify the mailbox and let Pozy keep access over time. | Property | Value | | --- | --- | | Platform id | `microsoft` | | Mailboxes | Exchange Online and Outlook.com. On-premises Exchange is not supported | | Organizations | Some tenants need an administrator to approve the app once ([admin consent](#admin-consent)) | | Inbox | New mail and API sends appear as inbox conversations, one per Outlook conversation | | List the accounts | `GET /v1/accounts?platform=microsoft` | ## Connect an account ```bash cURL curl "https://api.pozy.ai/v1/connect/microsoft?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https://app.example.com/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/microsoft?profileId=6710c3a9e4b0d2f1a8c95e21&scopes=email,calendar&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/microsoft", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6710c3a9e4b0d2f1a8c95e21", "scopes": "email,calendar", "redirect_url": "https://app.example.com/connected", }, ) ``` The user signs in, approves the permissions behind the areas, and returns to your `redirect_url` with `connected=microsoft`, `profileId`, `accountId` and `username`. Pozy reads the granted areas from the token Microsoft returns. To add areas later, connect the same account again with the longer list and `reconnectAccountId`. The account id stays the same. `loginHint` preselects the mailbox in Microsoft's account picker. Areas only add up; to drop one, disconnect and connect again. ## Admin consent Many work and school tenants let only an administrator approve apps that read mail, calendars, files or contacts. Users then see "Need admin approval" while connecting, or later calls return `412 admin_consent_required` with a ready-made link in `details.adminConsentUrl` (valid for one hour). Personal Outlook.com accounts never need this. Request a fresh link for an account in that organization and send it to an administrator (Global Administrator, Cloud Application Administrator or Application Administrator): ```bash cURL curl "https://api.pozy.ai/v1/connect/microsoft/admin-consent?accountId=6710c50ce4b0d2f1a8c95e82&redirectUrl=https://app.example.com/admin-done" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/microsoft/admin-consent?accountId=6710c50ce4b0d2f1a8c95e82&redirectUrl=https:%2F%2Fapp.example.com%2Fadmin-done", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.data.adminConsentUrl); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/connect/microsoft/admin-consent", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6710c50ce4b0d2f1a8c95e82", "redirectUrl": "https://app.example.com/admin-done"}, ) print(resp.json()["data"]["adminConsentUrl"]) ``` The administrator signs in, reviews the permissions and approves them for the whole tenant. Microsoft sends them to `redirectUrl` with `adminConsent=granted` and `tenant`, or `adminConsent=denied` and `error`. Calls that returned `412` then work without a reconnect. An administrator can also approve while connecting their own mailbox by ticking "Consent on behalf of your organization". Approval is per organization and covers all four areas. Each user still chooses what their own connection grants. An administrator can withdraw it in Microsoft Entra under Enterprise applications, after which accounts in that tenant fail until it is approved again. ## Outlook limits | Limit | Value | | --- | --- | | Sends a day | 1,000 per mailbox, reset at midnight UTC | | Same body to more than 50 recipients within an hour | The mailbox pauses: 10 minutes, doubling up to 24 hours, and `account.rate_limited` fires | | Addresses per field | 100 in `to`, 100 in `cc`, 100 in `bcc` | | Attachments | 20 files, none over 3 MB, 25 MB combined | | Custom headers | Only names that start with `X-`. A `List-Unsubscribe` header is removed, and `metadata.droppedHeaders` on the result says so | Each send response includes the `X-Pozy-Send-Limit`, `X-Pozy-Send-Remaining` and `X-Pozy-Send-Reset` headers. For repeating events Outlook takes a single RRULE, with no `EXDATE` or `RDATE`, and it stores just one reminder per event. Upload conversion with `convertTo` and picker sessions belong to Google; on Microsoft they answer `400`. ## Push notifications Pozy creates Microsoft Graph subscriptions for the granted areas and renews them before they expire (about three days for mail and calendar, about 29 days for OneDrive). Subscribe your own [webhook](https://pozy.ai/docs/webhooks) to the events you need: | Event | Area | Fires when | | --- | --- | --- | | `message.received`, `message.sent`, `conversation.started` | `email` | Mail arrives in the Inbox folder, is sent through the API, or starts a thread | | `calendar.event.created`, `.updated`, `.deleted` | `calendar` | An event in the user's calendar is added, edited or removed | | `file.created`, `file.updated`, `file.deleted` | `files` | A file in OneDrive is added, edited or removed | | `contact.updated`, `contact.deleted` | `contacts` | The once-a-day contact sync finds a changed or removed contact | | `account.rate_limited` | `email` | Sending from the mailbox is paused for a while | If the push subscription is down, mail received in that time produces no webhooks; catch up with `GET /v1/email/messages`. The subscription's state is the `push` field of `GET /v1/accounts/{accountId}/health`. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400 invalid_field_value` | `scopes` missing, or a Google-only option such as `google_meet` | Fix the field named in `param` | | `error=oauth_denied` on the redirect | The user said no, or their organization does not allow the app | Try again, or ask for [admin consent](#admin-consent) | | `401 reconnect_required` | Microsoft no longer accepts the account's access | Have the user reconnect | | `412 admin_consent_required` | The tenant needs an administrator's approval | Send `details.adminConsentUrl` to an administrator | | `412 account_scope_missing` | The account lacks the area | Reconnect with the area added | | `429 email_send_cap_reached`, `429 rate_limited` | Daily cap used, cooldown, or Graph throttling | Wait for `resetsAt` or `Retry-After` | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/microsoft/admin-consent`](https://pozy.ai/docs/api/startMicrosoftAdminConsent) | Admin-consent link for a Microsoft tenant | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | | Endpoint | What it does | | --- | --- | | [`GET /v1/calendar/calendars`](https://pozy.ai/docs/api/listCalendars) | List calendars | | [`GET /v1/calendar/events`](https://pozy.ai/docs/api/listCalendarEvents) | List events in a window | | [`POST /v1/calendar/events`](https://pozy.ai/docs/api/createCalendarEvent) | Create an event | | [`GET /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/getCalendarEvent) | Get an event | | [`PATCH /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/updateCalendarEvent) | Update an event | | [`DELETE /v1/calendar/events/{eventId}`](https://pozy.ai/docs/api/deleteCalendarEvent) | Delete an event | | [`POST /v1/calendar/events/{eventId}/rsvp`](https://pozy.ai/docs/api/rsvpCalendarEvent) | Respond to an invitation | | [`POST /v1/calendar/free-busy`](https://pozy.ai/docs/api/calendarFreeBusy) | Busy blocks for one or more accounts | | [`POST /v1/calendar/availability`](https://pozy.ai/docs/api/calendarAvailability) | Find meeting slots across accounts | | [`GET /v1/email/threads`](https://pozy.ai/docs/api/listEmailThreads) | List email threads | | [`GET /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/getEmailThread) | Get an email thread with its messages | | [`PATCH /v1/email/threads/{threadId}`](https://pozy.ai/docs/api/updateEmailThread) | Update every message in a thread | | [`GET /v1/email/messages`](https://pozy.ai/docs/api/listEmailMessages) | List email messages | | [`POST /v1/email/messages`](https://pozy.ai/docs/api/sendEmailMessage) | Send an email | | [`GET /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/getEmailMessage) | Get an email message | | [`PATCH /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/updateEmailMessage) | Mark read or starred, or move a message | | [`DELETE /v1/email/messages/{messageId}`](https://pozy.ai/docs/api/trashEmailMessage) | Move a message to trash | | [`POST /v1/email/messages/{messageId}/reply`](https://pozy.ai/docs/api/replyEmailMessage) | Reply to a message | | [`GET /v1/email/folders`](https://pozy.ai/docs/api/listEmailFolders) | List mailbox folders | | [`GET /v1/email/attachments/{attachmentId}`](https://pozy.ai/docs/api/downloadEmailAttachment) | Download an attachment | | [`GET /v1/files`](https://pozy.ai/docs/api/listFiles) | List files in a folder or search the drive | | [`POST /v1/files`](https://pozy.ai/docs/api/uploadFile) | Upload a file | | [`POST /v1/files/folders`](https://pozy.ai/docs/api/createFolder) | Create a folder | | [`POST /v1/files/picker-sessions`](https://pozy.ai/docs/api/createFilePickerSession) | Create a hosted Google Picker session (not available yet) | | [`GET /v1/files/{fileId}`](https://pozy.ai/docs/api/getFile) | Get a file or folder | | [`PATCH /v1/files/{fileId}`](https://pozy.ai/docs/api/updateFile) | Rename or move a file | | [`DELETE /v1/files/{fileId}`](https://pozy.ai/docs/api/trashFile) | Move a file to the trash | | [`GET /v1/files/{fileId}/download`](https://pozy.ai/docs/api/downloadFile) | Download a file | | [`GET /v1/files/{fileId}/share-link`](https://pozy.ai/docs/api/getFileShareLink) | Get an anyone-with-the-link share link | --- # Analytics Read reach, engagement, followers and inbox activity for every connected account, and keep your own copy fresh with a cursor feed and a webhook. Pozy collects metrics from every connected account on a schedule and serves them through one set of endpoints. You get per-post numbers, account-level insights, follower history, timing recommendations and inbox activity, all filtered by the same `profileId`, `accountId` and `platform` parameters. The dashboard's **Analytics** pages read these same endpoints, so a chart you see in the app is a request you can make yourself. ## What Pozy measures | Area | What you get | Start with | | --- | --- | --- | | Posts | Impressions, reach, likes, comments, shares, saves, clicks, views and platform extras for every post | [Post analytics](https://pozy.ai/docs/analytics/post-analytics) | | Accounts | Account insights, follower history, demographics, channel and page totals | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | | Timing | Best hour of the week to post, posting frequency against engagement | [Best time to post](https://pozy.ai/docs/analytics/best-time) | | Lifespan | How fast a post collects its engagement after publishing | [Content decay](https://pozy.ai/docs/analytics/content-decay) | | Verified tracking | A watched window per post, raw samples and an authenticity score | [Tracking and authenticity](https://pozy.ai/docs/analytics/tracking) | | Inbox | Message volume, response times, busiest hours, top accounts | [Inbox analytics](https://pozy.ai/docs/analytics/inbox-analytics) | ## One call for an overview screen `GET /v1/analytics/dashboard` returns totals, the previous period for comparison, follower growth, a daily series, top posts and recent posts in a single response. It is the fastest way to fill an overview page. ```bash cURL curl "https://api.pozy.ai/v1/analytics/dashboard?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-10-01&toDate=2026-10-29&compare=previous_period" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/dashboard?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-10-01&toDate=2026-10-29&compare=previous_period", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.totals.impressions, data.previousTotals?.impressions); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics/dashboard", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6650c1e2a3b4c5d6e7f80911", "fromDate": "2026-10-01", "toDate": "2026-10-29", "compare": "previous_period", }, timeout=30, ) data = resp.json() print(data["totals"]["impressions"]) ``` ```json { "dateRange": { "fromDate": "2026-10-01", "toDate": "2026-10-29" }, "totals": { "impressions": 48210, "reach": 31987, "likes": 2114, "comments": 302, "shares": 140, "saves": 211, "clicks": 590, "views": 22340, "engagementRate": 5.86 }, "previousTotals": { "impressions": 40112, "reach": 27650, "likes": 1802, "comments": 251, "shares": 98, "saves": 180, "clicks": 470, "views": 19011, "engagementRate": 5.8 }, "followers": { "current": 12840, "gained": 312, "byAccount": [{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "platform": "instagram", "current": 12840, "gained": 312 }] }, "daily": [{ "date": "2026-10-01", "impressions": 1630, "reach": 1210, "engagement": 96, "views": 702, "followersGained": 9 }], "topPosts": [], "recentPosts": [], "dataAsOf": "2026-10-29T22:40:00.000Z" } ``` | Parameter | Meaning | | --- | --- | | `fromDate`, `toDate` | Required. UTC days as `YYYY-MM-DD`, both inclusive, up to 366 days apart | | `profileId`, `platform` | Narrow to one customer profile or one platform. Both default to everything | | `compare=previous_period` | Adds `previousTotals` and `previousFollowers` for the window of the same length that ends the day before `fromDate` | | `topPosts`, `recentPosts` | How many posts to list, 0 to 25. Defaults are 5 and 10 | ### Two kinds of numbers The dashboard mixes two views of the same data, and they answer different questions: - `totals`, `previousTotals` and `daily` count engagement that **arrived** during the window, on any post. A post from last month that gets 10 likes this week adds 10 to this week. `totals` always equals the sum of `daily`. - `topPosts` and `recentPosts` list posts **published** during the window, each with its lifetime metrics. So the top posts do not add up to the totals, and they are not meant to. `engagementRate` is a percentage: likes, comments, shares and saves divided by impressions. Where a platform reports no impressions, Pozy falls back to reach, then views. ## Posts made outside Pozy Every analytics endpoint takes `source`: | `source` | Includes | | --- | --- | | `all` (default) | Everything | | `pozy` | Posts you published through Pozy | | `external` | Posts published directly on the platform, picked up by a background sync | Pozy discovers external posts on each account about every 90 minutes. When someone pastes the URL of a post they published a minute ago, fetch it right away with [`POST /v1/posts/sync-external`](https://pozy.ai/docs/api/syncExternalPosts) and the `accountId` plus `url`. That call is debounced per account, so use it for that moment, not as a bulk refresh. See [External posts](https://pozy.ai/docs/publishing/external-posts). ## How fresh the numbers are Platforms release metrics on their own schedule. Show a "data as of" label instead of promising real time. | Data | Typical freshness | | --- | --- | | Post likes and comments | About an hour. A stale read triggers a background refresh | | Post reach and impressions | Platform dependent. About 24 hours on Instagram | | Follower counts | Once a day | | External posts | Discovered about every 90 minutes per account | | YouTube daily views and channel totals | 2 to 3 days behind | | Google Business Profile performance | 2 to 3 days behind | Use the freshness fields the API returns: `dataAsOf` on the dashboard, `overview.lastSync` and `overview.dataStaleness` on post lists, `syncStatus` per platform entry (`synced`, `pending`, `unavailable`) and `analytics.lastUpdated` per post. Show a spinner on `pending` rather than a zero. ## Keep your own copy in sync If you store metrics in your own database, do not poll `GET /v1/analytics` once per account. Use the change feed: ### Take a cursor first Call [`GET /v1/analytics/delta`](https://pozy.ai/docs/api/getAnalyticsDelta) with no `cursor`. It returns an empty `data` array and the feed's current position in `nextCursor`. ### Load your baseline Page through [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) once, at most 366 days per window. ### Resume from the cursor you took before the baseline Call the delta feed with that cursor and keep sending back each `nextCursor`. `hasMore: true` means call again straight away. `hasMore: false` means you are caught up, so wait for your next interval. Taking the cursor before the baseline matters. Changes that land while the baseline loads would otherwise be in neither. The overlap is safe because metrics are absolute values, not increments: a later row for a post replaces an earlier one. > **Empty pages are not "no changes":** > > The feed holds back its last few seconds of writes. A read made the instant a change happens can come back empty. Poll again with the **same** cursor instead of advancing. The feed keeps 7 days of changes, and a cursor older than 6 days is rejected with a `400`, so read at least once a day. If you fall behind, take a fresh cursor and reload the baseline. Treat the cursor as opaque. Do not parse it, build it or sort on `syncedAt`, which can go backwards between entries. ### Get told when an account changes Subscribe a webhook to `analytics.synced`. It fires once per account each time its analytics sync finishes, which is roughly hourly per account. The payload names the account and how many posts changed, and it carries no metrics: read the delta feed when it arrives. On a busy workspace this event is high volume (1,000 connected accounts means about 1,000 events an hour). Give it its own webhook endpoint so a slow consumer cannot get your other events switched off, and return `2xx` before you read the feed. See [Retries](https://pozy.ai/docs/webhooks/retries). The event is skipped when a sync fails, when the account was synced moments ago, when the account is disconnected, or when the platform has no analytics. ## Errors you may see | Status | When | | --- | --- | | `202` | A single-post lookup (`?postId=`) whose sync is still running. Retry shortly | | `424` | A single-post lookup where every platform failed to sync | | `409` | An inactive account or one that needs reconnecting. Stop polling it until it is [reconnected](https://pozy.ai/docs/connect/account-health) | | `429` | You hit the workspace [rate limit](https://pozy.ai/docs/getting-started/rate-limits). Wait for `Retry-After`, then retry the same page | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics/dashboard`](https://pozy.ai/docs/api/getAnalyticsDashboard) | Get an analytics dashboard | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | | [`GET /v1/analytics/delta`](https://pozy.ai/docs/api/getAnalyticsDelta) | Analytics changed since a cursor | | [`GET /v1/analytics/daily-metrics`](https://pozy.ai/docs/api/getDailyMetrics) | Get daily aggregated metrics | | [`GET /v1/accounts/follower-stats`](https://pozy.ai/docs/api/getFollowerStats) | Get follower stats | | [`GET /v1/analytics/post-timeline`](https://pozy.ai/docs/api/getPostTimeline) | Get post analytics timeline | | [`POST /v1/posts/sync-external`](https://pozy.ai/docs/api/syncExternalPosts) | Sync an external post | ## Webhook events | Event | When it fires | | --- | --- | | [`analytics.synced`](https://pozy.ai/docs/webhooks/events#analytics-synced) | Analytics synced. | | [`post.tracking_completed`](https://pozy.ai/docs/webhooks/events#post-tracking-completed) | Post tracking completed. | | [`post.authenticity_changed`](https://pozy.ai/docs/webhooks/events#post-authenticity-changed) | Post authenticity changed. | --- # Post analytics List every post with its metrics, sort and filter by engagement, look up one post across platforms, and chart day-by-day performance. `GET /v1/analytics` returns your posts with their metrics. Without `postId` it is a paginated list with an overview block. With `postId` it returns one post and a breakdown per platform it went to. ## List posts with metrics ```bash cURL curl "https://api.pozy.ai/v1/analytics?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-09-01&sortBy=engagement&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-09-01&sortBy=engagement&limit=50", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const post of data.posts) { console.log(post.platform, post.analytics.likes, post.analytics.engagementRate); } ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1e2a3b4c5d6e7f80911", "fromDate": "2026-09-01", "sortBy": "engagement", "limit": 50}, timeout=30, ) for post in resp.json()["posts"]: print(post["platform"], post["analytics"]["likes"]) ``` ```json { "overview": { "totalPosts": 142, "publishedPosts": 140, "scheduledPosts": 2, "lastSync": "2026-10-29T21:12:00Z", "dataStaleness": { "staleAccountCount": 0, "syncTriggered": false } }, "posts": [ { "_id": "6650c1e2a3b4c5d6e7f80e66", "platform": "instagram", "status": "published", "publishedAt": "2026-10-02T14:00:03Z", "platformPostUrl": "https://www.instagram.com/p/C7xYz12AbCd/", "analytics": { "impressions": 4210, "reach": 3105, "likes": 312, "comments": 41, "shares": 18, "saves": 27, "views": 3890, "engagementRate": 9.33, "lastUpdated": "2026-10-29T21:12:00Z" }, "platforms": [{ "platform": "instagram", "syncStatus": "synced" }] } ], "pagination": { "page": 1, "limit": 50, "total": 142, "pages": 3 } } ``` ## Filters and sorting | Parameter | What it does | | --- | --- | | `profileId`, `accountId`, `platform` | Narrow to a customer profile, one account or one platform | | `source` | `all` (default), `pozy` for posts published through Pozy, `external` for posts made on the platform itself | | `fromDate`, `toDate` | Bound the publish date. `fromDate` defaults to 90 days ago. The range is at most 366 days | | `tags` | Only posts carrying these tags | | `sortBy` | `date` (default), `engagement`, `impressions`, `reach`, `likes`, `comments`, `shares`, `saves`, `clicks`, `views`, `follows`, `reposts`, `profile_views`, `website_clicks`, `completion_rate`, `reels_skip_rate`, `ig_reels_avg_watch_time`, `ig_reels_video_view_total_time` | | `order` | `desc` (default) or `asc` | | `limit`, `page` | Up to 100 posts per page. Use `pagination.pages` to know when to stop | A post sent to several platforms appears once per platform in the list, so key your own rows on post id plus platform. ## The metrics Every post carries the same `analytics` object. A metric a platform does not report is `0` or `null`; check [Platform insights](https://pozy.ai/docs/analytics/platform-insights) for what each platform fills in. | Field | Meaning | | --- | --- | | `impressions` | Times the post was shown | | `reach` | Unique people who saw it | | `likes`, `comments`, `shares`, `saves` | Engagement counters as the platform reports them | | `clicks` | Link or profile clicks the post generated | | `views` | Video or media views | | `follows` | Followers gained from the post, where the platform says so | | `reposts` | Reposts, where counted separately from shares | | `profileViews`, `websiteClicks` | Profile visits and website taps the post brought in | | `completionRate` | Share of viewers who watched to the end, from 0 to 1 | | `igReelsAvgWatchTime`, `igReelsVideoViewTotalTime` | Average and total watch time in milliseconds (Instagram Reels and TikTok business accounts) | | `reelsSkipRate` | Share of viewers who skipped a reel early | | `impressionSources`, `audienceTypes`, `audienceCountries` | Breakdowns as shares from 0 to 1, where the platform provides them | | `retentionCurve` | Share of viewers still watching at each second | | `engagementRate` | Likes, comments, shares and saves over impressions, as a percentage | | `lastUpdated` | When Pozy last refreshed these numbers | ## One post across platforms Pass `postId` (a Pozy post id or an external post id) to get a single post with a `platformAnalytics` array, one entry per platform it was published to: ```bash cURL curl "https://api.pozy.ai/v1/analytics?postId=6650c1e2a3b4c5d6e7f80e66" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics?postId=6650c1e2a3b4c5d6e7f80e66", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const p of data.platformAnalytics) { console.log(p.platform, p.syncStatus, p.analytics?.views); } ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/analytics", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"postId": "6650c1e2a3b4c5d6e7f80e66"}, timeout=30, ) print(resp.json()["platformAnalytics"]) ``` Each `platformAnalytics` entry has its own `syncStatus` (`synced`, `pending`, `unavailable`), `platformPostUrl` and, when something went wrong, `errorCode`: `post_not_found` (deleted on the platform), `not_post_owner` or `permission_missing`. > **Note:** > > A single-post lookup answers `202` while its first sync is still running. Retry after a short wait. It answers `424` when every platform failed to sync. ## Day by day Two endpoints give you a time series instead of lifetime totals. **One post over time.** [`GET /v1/analytics/post-timeline`](https://pozy.ai/docs/api/getPostTimeline) with `postId` returns one row per day per platform, showing how impressions, likes and the other counters grew since publishing. **All posts, summed per day.** [`GET /v1/analytics/daily-metrics`](https://pozy.ai/docs/api/getDailyMetrics) returns a `dailyData` array (post count, platform split and summed metrics per day) plus a `platformBreakdown`. It defaults to the last 180 days. Its `attribution` parameter decides what a day means: | `attribution` | A day shows | Answers | | --- | --- | --- | | `publish` (default) | Lifetime totals of the posts published that day | "How did what I published this week do?" | | `received` | Engagement that arrived that day, on any post | "How much engagement did the account get this week?" | A post published on 1 July gets 40 likes that day and 10 more on 20 July. Under `publish`, all 50 sit on 1 July. Under `received`, 1 July shows 40 and 20 July shows 10. Use `received` for account overview charts, because it moves even in weeks with no new posts. ## Followers Follower counts live on the accounts side: [`GET /v1/accounts/follower-stats`](https://pozy.ai/docs/api/getFollowerStats) takes `profileId` or `accountIds`, a date range and `granularity` (`daily`, `weekly` or `monthly`). Counts refresh once a day, so a daily job is enough. ## Exports There is no separate export endpoint. To export, page through `GET /v1/analytics` (100 posts per page) or the delta feed and write the rows to CSV yourself. Each row already has the post URL, platform, publish time and every metric. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics`](https://pozy.ai/docs/api/getAnalytics) | Get post analytics | | [`GET /v1/analytics/post-timeline`](https://pozy.ai/docs/api/getPostTimeline) | Get post analytics timeline | | [`GET /v1/analytics/daily-metrics`](https://pozy.ai/docs/api/getDailyMetrics) | Get daily aggregated metrics | | [`GET /v1/analytics/delta`](https://pozy.ai/docs/api/getAnalyticsDelta) | Analytics changed since a cursor | | [`GET /v1/accounts/follower-stats`](https://pozy.ai/docs/api/getFollowerStats) | Get follower stats | | [`POST /v1/posts/sync-external`](https://pozy.ai/docs/api/syncExternalPosts) | Sync an external post | --- # Platform insights Which metrics each platform returns for posts, and the account-level endpoints for Instagram, Facebook, YouTube, LinkedIn, TikTok and Google Business Profile. Every platform exposes a different set of numbers. Pozy normalises them into one `analytics` object per post, then adds account-level endpoints for the platforms that offer more. This page lists what you can count on from each. ## Post metrics by platform `GET /v1/analytics?platform=` returns these fields per post. A dash means the platform does not report it, so the field reads `0` or `null`. | Platform | Impressions | Reach | Likes | Comments | Shares | Saves | Clicks | Views | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Instagram | Yes | Yes | Yes | Yes | Yes | Yes | – | Yes | | Facebook | Yes | – | Yes | Yes | Yes | – | Yes | Yes | | TikTok | – | Business | Yes | Yes | Yes | Business | Business | Yes | | YouTube | – | – | Yes | Yes | Daily views only | – | – | Yes | | LinkedIn | Yes | Yes | Yes | Yes | Yes | Personal only | Pages only | Video only | | X | Yes | – | Yes | Yes | Yes | Yes (bookmarks) | Yes | Media only | | Threads | – | – | Yes | Yes | Yes | – | – | Yes | | Pinterest | Yes | – | – | – | – | Yes | Yes | Video pins | | Bluesky | – | – | Yes | Yes | Yes (reposts) | – | – | – | | Snapchat | – | Yes | – | – | Yes | – | – | Yes | | Reddit (paused) | – | – | Yes (upvotes) | Yes | – | – | – | – | "Business" means accounts connected through TikTok's business app, which every new TikTok connection uses. ### Platform notes - **Instagram.** Post reach and impressions can take about a day to settle. Image and cover URLs in analytics responses are served from Pozy storage once the platform's own links expire, so you can cache them. - **Facebook.** `likes` is the total of all reaction types. [Post reactions](https://pozy.ai/docs/api/getFacebookPostReactions) splits it by type. Page stories are collected while live and keep their last reading after 24 hours. - **TikTok.** Business connections also report watch time, completion rate, profile views, new followers, website clicks, impression sources, audience types, audience countries and a retention curve. TikTok fills these 24 to 48 hours after publishing and only for posts active in the last 7 days, so a fresh post shows zeros at first. Account-level impressions and reach do not exist on any TikTok API. - **YouTube.** Per-day shares come from [daily views](https://pozy.ai/docs/api/getYouTubeDailyViews). Thumbnail impressions and click-through rate are not available through YouTube's API. - **LinkedIn.** Personal profiles only return metrics for posts published through Pozy, because LinkedIn reports only on posts the member authored through the app. Company pages report every post. - **X.** `views` is the media view count, so text and image posts read `0`. Every X read is billed per call; see [pricing](https://pozy.ai/docs/billing). - **Threads.** There is no impressions metric; use `views` as reach. `shares` adds reposts and quotes together. - **Pinterest.** `views` counts video views, so image pins read `0`. - **Snapchat.** Metrics come per content type (story, saved story, spotlight). Snapchat has no likes, comments or saves. - **Reddit.** Reddit closed its API to third-party apps. Reddit analytics keep their last synced values and stop updating until access returns. - **Google Business Profile.** Google retired per-post insights, so posts carry no metrics. Use the location performance endpoints below. ## Account-level insights Most account endpoints share the same query shape: `accountId`, `fromDate` and `toDate` (defaults to the last 30 days, at most about 90), `metrics` as a comma-separated list, and `metricType` as `total_value` (default) or `time_series`. ```bash cURL curl "https://api.pozy.ai/v1/analytics/instagram/account-insights?accountId=6650c1e2a3b4c5d6e7f80a22&metrics=reach&metricType=time_series&fromDate=2026-10-01&toDate=2026-10-28" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/instagram/account-insights?accountId=6650c1e2a3b4c5d6e7f80a22&metrics=reach&metricType=time_series&fromDate=2026-10-01&toDate=2026-10-28", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics/instagram/account-insights", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6650c1e2a3b4c5d6e7f80a22", "metrics": "reach", "metricType": "time_series"}, timeout=30, ) print(resp.json()) ``` ### Instagram | Endpoint | Returns | Notes | | --- | --- | --- | | [`GET /v1/analytics/instagram/account-insights`](https://pozy.ai/docs/api/getInstagramAccountInsights) | Reach, views, accounts engaged, total interactions, follows and unfollows, profile link taps, profile views, website clicks | Only `reach` supports `time_series`. Up to 48 hours of delay | | [`GET /v1/analytics/instagram/follower-history`](https://pozy.ai/docs/api/getInstagramFollowerHistory) | Daily follower count with `followers_gained` and `followers_lost` | Built from Pozy's daily snapshots, since Instagram no longer serves a history | | [`GET /v1/analytics/instagram/demographics`](https://pozy.ai/docs/api/getInstagramDemographics) | Followers or engaged audience by age, city, country or gender | Needs at least 100 followers. Top 45 entries per dimension | | [`GET /v1/analytics/instagram/online-followers`](https://pozy.ai/docs/api/getInstagramOnlineFollowers) | Followers online per hour, for the last 30 days | Needs at least 100 followers | | [`GET /v1/accounts/{accountId}/instagram/stories/{storyId}/insights`](https://pozy.ai/docs/api/getInstagramStoryInsights) | Views, reach, replies, shares, profile visits, follows, taps and exits for one story | Read it before the story expires. Counts under 5 can come back as 0 | ### Facebook | Endpoint | Returns | Notes | | --- | --- | --- | | [`GET /v1/analytics/facebook/page-insights`](https://pozy.ai/docs/api/getFacebookPageInsights) | Media views, post engagements, video metrics, follower counts, `followers_gained` and `followers_lost` | The retired metrics `page_impressions`, `page_fans`, `page_fan_adds` and `page_fan_removes` are rejected | | [`GET /v1/analytics/facebook/demographics`](https://pozy.ai/docs/api/getFacebookDemographics) | Followers by country or city | Country keys are ISO codes | | [`GET /v1/accounts/{accountId}/facebook-post-reactions`](https://pozy.ai/docs/api/getFacebookPostReactions) | Count per reaction type for one post | | | [`GET /v1/analytics/facebook/post-earnings`](https://pozy.ai/docs/api/getFacebookPostEarnings) | Lifetime monetization earnings for one post | Cumulative, never sum across dates. `0` when the Page is not monetized | ### YouTube | Endpoint | Returns | Notes | | --- | --- | --- | | [`GET /v1/analytics/youtube/channel-insights`](https://pozy.ai/docs/api/getYouTubeChannelInsights) | Channel views, watch time, average view duration, subscribers gained and lost | 2 to 3 days of delay | | [`GET /v1/analytics/youtube/daily-views`](https://pozy.ai/docs/api/getYouTubeDailyViews) | Per-day views, watch time, subscriber changes, likes, comments and shares for one video | Only finalized days by default | | [`GET /v1/analytics/youtube/video-retention`](https://pozy.ai/docs/api/getYouTubeVideoRetention) | Audience retention curve of one video | Up to 100 points | | [`GET /v1/analytics/youtube/demographics`](https://pozy.ai/docs/api/getYouTubeDemographics) | Viewers by age, gender and country, for the channel or one video | Age and gender are percentages, country values are view counts. Signed-in viewers only | ### LinkedIn | Endpoint | Returns | Notes | | --- | --- | --- | | [`GET /v1/analytics/linkedin/org-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInOrgAggregateAnalytics) | Page impressions, clicks, reactions, engagement rate, follower gains, page views | The member must be a page administrator. Page-view metrics accept `total_value` only | | [`GET /v1/accounts/{accountId}/linkedin-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInAggregateAnalytics) | Totals or a daily series across a personal profile's posts, including saves and sends | `aggregation` is `TOTAL` or `DAILY` | | [`GET /v1/accounts/{accountId}/linkedin-post-analytics`](https://pozy.ai/docs/api/getLinkedInPostAnalytics) | Metrics for one post by URN | Saves and sends are personal only | | [`GET /v1/accounts/{accountId}/linkedin-post-reactions`](https://pozy.ai/docs/api/getLinkedInPostReactions) | Individual reactions with reactor profiles | Company pages only | | [`GET /v1/analytics/linkedin/demographics`](https://pozy.ai/docs/api/getLinkedInDemographics) | Followers by job function, seniority, industry, company size, country and region | Top 100 values per breakdown | ### TikTok [`GET /v1/analytics/tiktok/account-insights`](https://pozy.ai/docs/api/getTikTokAccountInsights) returns `follower_count`, `following_count`, `likes_count` and `video_count`, plus `followers_gained` and `followers_lost` from Pozy's daily snapshots. Richer numbers are per video: see the TikTok row in the table above. ### Google Business Profile | Endpoint | Returns | Notes | | --- | --- | --- | | [`GET /v1/analytics/googlebusiness/performance`](https://pozy.ai/docs/api/getGoogleBusinessPerformance) | Daily impressions (desktop and mobile, Maps and Search), website clicks, call clicks, direction requests, conversations, bookings, food orders and menu clicks | Up to 18 months back. 2 to 3 days of delay | | [`GET /v1/analytics/googlebusiness/search-keywords`](https://pozy.ai/docs/api/getGoogleBusinessSearchKeywords) | Monthly search keywords that showed the listing | Low-volume keywords come back with `impressionsThreshold` instead of a count. Page with `pageToken` | ### Google Analytics A connected Google Analytics property can be queried with [`POST /v1/analytics/googleanalytics/report`](https://pozy.ai/docs/api/runGoogleAnalyticsReport) and its realtime variant. List valid dimensions and metrics first with [`GET /v1/analytics/googleanalytics/metadata`](https://pozy.ai/docs/api/getGoogleAnalyticsMetadata). See [Google Analytics](https://pozy.ai/docs/platforms/google-analytics). ## Missing permissions When an account was connected before a platform permission existed, account endpoints answer `412` with a hint to reauthorize (YouTube Analytics, TikTok stats, LinkedIn page analytics). Send the user through [reconnect](https://pozy.ai/docs/connect/account-health) and retry. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics/youtube/channel-insights`](https://pozy.ai/docs/api/getYouTubeChannelInsights) | Get YouTube channel insights | | [`GET /v1/analytics/linkedin/org-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInOrgAggregateAnalytics) | Get LinkedIn org analytics | | [`GET /v1/analytics/tiktok/account-insights`](https://pozy.ai/docs/api/getTikTokAccountInsights) | Get TikTok account-level insights | | [`GET /v1/analytics/youtube/daily-views`](https://pozy.ai/docs/api/getYouTubeDailyViews) | Get YouTube daily views | | [`GET /v1/analytics/youtube/video-retention`](https://pozy.ai/docs/api/getYouTubeVideoRetention) | Get YouTube video retention curve | | [`GET /v1/analytics/facebook/page-insights`](https://pozy.ai/docs/api/getFacebookPageInsights) | Get Facebook Page insights | | [`GET /v1/analytics/facebook/post-earnings`](https://pozy.ai/docs/api/getFacebookPostEarnings) | Get Facebook post monetization earnings | | [`GET /v1/analytics/instagram/account-insights`](https://pozy.ai/docs/api/getInstagramAccountInsights) | Get Instagram insights | | [`GET /v1/analytics/instagram/follower-history`](https://pozy.ai/docs/api/getInstagramFollowerHistory) | Get Instagram follower history | | [`GET /v1/analytics/facebook/demographics`](https://pozy.ai/docs/api/getFacebookDemographics) | Get Facebook Page demographics | | [`GET /v1/analytics/linkedin/demographics`](https://pozy.ai/docs/api/getLinkedInDemographics) | Get LinkedIn organization follower demographics | | [`GET /v1/analytics/instagram/demographics`](https://pozy.ai/docs/api/getInstagramDemographics) | Get Instagram demographics | | [`GET /v1/analytics/instagram/online-followers`](https://pozy.ai/docs/api/getInstagramOnlineFollowers) | Get Instagram online followers | | [`GET /v1/analytics/youtube/demographics`](https://pozy.ai/docs/api/getYouTubeDemographics) | Get YouTube demographics | | [`POST /v1/analytics/googleanalytics/report`](https://pozy.ai/docs/api/runGoogleAnalyticsReport) | Run a Google Analytics (GA4) report | | [`POST /v1/analytics/googleanalytics/realtime`](https://pozy.ai/docs/api/runGoogleAnalyticsRealtimeReport) | Run a Google Analytics (GA4) realtime report | | [`GET /v1/analytics/googleanalytics/metadata`](https://pozy.ai/docs/api/getGoogleAnalyticsMetadata) | List Google Analytics (GA4) dimensions and metrics | | [`GET /v1/analytics/googlebusiness/performance`](https://pozy.ai/docs/api/getGoogleBusinessPerformance) | Get Google Business Profile performance metrics | | [`GET /v1/analytics/googlebusiness/search-keywords`](https://pozy.ai/docs/api/getGoogleBusinessSearchKeywords) | Get Google Business Profile search keywords | | [`GET /v1/accounts/{accountId}/linkedin-aggregate-analytics`](https://pozy.ai/docs/api/getLinkedInAggregateAnalytics) | Get LinkedIn aggregate stats | | [`GET /v1/accounts/{accountId}/linkedin-post-analytics`](https://pozy.ai/docs/api/getLinkedInPostAnalytics) | Get LinkedIn post stats | | [`GET /v1/accounts/{accountId}/linkedin-post-reactions`](https://pozy.ai/docs/api/getLinkedInPostReactions) | Get LinkedIn post reactions | | [`GET /v1/accounts/{accountId}/facebook-post-reactions`](https://pozy.ai/docs/api/getFacebookPostReactions) | Get Facebook post reactions | | [`GET /v1/accounts/{accountId}/facebook-visitor-posts`](https://pozy.ai/docs/api/listFacebookVisitorPosts) | List visitor posts on a Facebook Page | | [`GET /v1/accounts/{accountId}/facebook-tagged-posts`](https://pozy.ai/docs/api/listFacebookTaggedPosts) | List the posts a Facebook Page is tagged in | --- # Best time to post Find the hours of the week when your own posts get the most engagement, and how often to post on each platform, from your publishing history. `GET /v1/analytics/best-time` looks at the posts an account (or a whole profile) already published and tells you which hours of the week earned the most engagement. It is your own history, not an industry average, so it gets better as you post more. ## Get the best slots ```bash cURL curl "https://api.pozy.ai/v1/analytics/best-time?accountId=6650c1e2a3b4c5d6e7f80a22" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/best-time?accountId=6650c1e2a3b4c5d6e7f80a22", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const top = [...data.slots] .filter((s) => s.post_count >= 3) .sort((a, b) => b.avg_engagement - a.avg_engagement) .slice(0, 5); console.log(top); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics/best-time", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6650c1e2a3b4c5d6e7f80a22"}, timeout=30, ) slots = [s for s in resp.json()["slots"] if s["post_count"] >= 3] print(sorted(slots, key=lambda s: s["avg_engagement"], reverse=True)[:5]) ``` ```json { "slots": [ { "day_of_week": 1, "hour": 17, "avg_engagement": 412.5, "post_count": 8 }, { "day_of_week": 3, "hour": 12, "avg_engagement": 388.0, "post_count": 6 }, { "day_of_week": 6, "hour": 9, "avg_engagement": 120.3, "post_count": 2 } ] } ``` ## How to read a slot | Field | Meaning | | --- | --- | | `day_of_week` | `0` is Monday, `6` is Sunday | | `hour` | Hour of the day in **UTC**, 0 to 23 | | `avg_engagement` | Average likes, comments, shares and saves of the posts published in that slot | | `post_count` | How many posts fell in that slot | Pozy groups every published post by the weekday and UTC hour it went out, then averages engagement per group. Slots with no posts are left out, so a sparse history returns few slots. > **Trust slots with enough posts:** > > A slot with one viral post looks great and means little. Filter on `post_count` (3 or more is a reasonable floor) before you rank. Until an account has a few weeks of history, combine this with [online followers](#when-followers-are-online) on Instagram. ### Filters | Parameter | Use | | --- | --- | | `accountId` | One account. Usually what you want, since audiences differ per account | | `profileId` | Every account in a customer profile | | `platform` | One platform across the profile | | `source` | `all` (default), `pozy` for posts published through Pozy, `external` for posts made on the platform | ## Convert to the account's timezone Slots are in UTC. Convert them before you show them or schedule against them: ```ts function slotToLocal(slot: { day_of_week: number; hour: number }, timeZone: string) { // Pick any Monday at 00:00 UTC, then move to the slot. const base = Date.UTC(2026, 9, 26); // Monday 26 October 2026 const at = new Date(base + (slot.day_of_week * 24 + slot.hour) * 3_600_000); return new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "long", hour: "2-digit", minute: "2-digit" }).format(at); } slotToLocal({ day_of_week: 1, hour: 17 }, "America/New_York"); // "Tuesday 13:00" ``` ## Schedule into the best slots Two common patterns: - **Pick a time per post.** Take the best slot after now and pass it as `scheduledFor` when you [create the post](https://pozy.ai/docs/publishing). Keep the timezone explicit, as described in [Scheduling](https://pozy.ai/docs/publishing/scheduling). - **Feed a queue.** Turn the top slots into a recurring queue schedule, so every post you add goes out at one of them. See [Queues](https://pozy.ai/docs/publishing/queues). ## How often to post [`GET /v1/analytics/posting-frequency`](https://pozy.ai/docs/api/getPostingFrequency) answers a different question: does posting more often help or hurt? It groups past weeks by how many posts went out and reports the engagement of each group, per platform. | Field | Meaning | | --- | --- | | `platform` | The platform the row describes | | `posts_per_week` | A posting cadence that occurred in your history | | `avg_engagement_rate` | Average engagement rate (0 to 100) across the weeks with that cadence | | `avg_engagement` | Average raw engagement (likes, comments, shares and saves) | | `weeks_count` | How many calendar weeks had that cadence | Look for the cadence where the engagement rate stops rising. As with slots, ignore rows backed by one or two weeks. ## When followers are online For Instagram accounts with at least 100 followers, [`GET /v1/analytics/instagram/online-followers`](https://pozy.ai/docs/api/getInstagramOnlineFollowers) returns how many followers were online in each hour of each of the last 30 days. It works before you have a posting history. Instagram does not document the timezone of its hour keys, so compare it with your own best-time slots before relying on the exact hour. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics/best-time`](https://pozy.ai/docs/api/getBestTimeToPost) | Get best times to post | | [`GET /v1/analytics/posting-frequency`](https://pozy.ai/docs/api/getPostingFrequency) | Get frequency vs engagement | | [`GET /v1/analytics/instagram/online-followers`](https://pozy.ai/docs/api/getInstagramOnlineFollowers) | Get Instagram online followers | --- # Content decay See how quickly your posts collect their engagement after publishing, per platform, and use it to time reposts, boosts and reporting. Most posts get the bulk of their likes and comments early, then flatten out. How early depends on the platform and your audience. `GET /v1/analytics/content-decay` measures it from your own posts, so you can say things like "our Instagram posts reach most of their engagement within a day" with data behind it. ## Get the decay curve ```bash cURL curl "https://api.pozy.ai/v1/analytics/content-decay?profileId=6650c1e2a3b4c5d6e7f80911&platform=instagram" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/content-decay?profileId=6650c1e2a3b4c5d6e7f80911&platform=instagram", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const b of [...data.buckets].sort((a, z) => a.bucket_order - z.bucket_order)) { console.log(`${b.bucket_label}: ${b.avg_pct_of_final.toFixed(0)}% of final engagement (${b.post_count} posts)`); } ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics/content-decay", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1e2a3b4c5d6e7f80911", "platform": "instagram"}, timeout=30, ) for b in sorted(resp.json()["buckets"], key=lambda b: b["bucket_order"]): print(b["bucket_label"], round(b["avg_pct_of_final"]), b["post_count"]) ``` ## How to read a bucket Each bucket is a time window after publishing. Its value is the share of a post's eventual engagement that had already arrived by the end of that window, averaged over your posts. | Field | Meaning | | --- | --- | | `bucket_order` | Position from `0` (soonest after publishing) to `6` (latest). Sort on this, not on the label | | `bucket_label` | Human-readable name of the window, ready for a chart axis | | `avg_pct_of_final` | Average percentage (0 to 100) of final engagement reached by then | | `post_count` | Posts with data for that bucket | The curve only ever rises. A steep start followed by a flat line means your posts burn out fast; a slow climb means they keep earning for days. ## What to do with it - **Report at the right moment.** If 90% of engagement arrives in the first two windows, a "how did it do" report can run then without waiting a week. - **Time a boost.** Boosting while a post is still climbing gets more organic lift than boosting a flat one. See [Boost a post](https://pozy.ai/docs/ads/boost-posts). - **Space reposts.** Repost or recycle once the curve flattens, not before. See [Queues](https://pozy.ai/docs/publishing/queues). - **Compare platforms.** Run it once per `platform`. Short-lived feeds and search-driven platforms usually look very different. ## Filters | Parameter | Use | | --- | --- | | `platform` | One platform. Recommended, since mixing platforms blurs the curve | | `profileId`, `accountId` | One customer profile or one account | | `source` | `all` (default), `pozy` or `external` | ## Related: one post over time The decay curve averages many posts. To see one post's own growth, use [`GET /v1/analytics/post-timeline`](https://pozy.ai/docs/api/getPostTimeline), which returns daily rows per platform since it was published. See [Post analytics](https://pozy.ai/docs/analytics/post-analytics#day-by-day). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics/content-decay`](https://pozy.ai/docs/api/getContentDecay) | Get content performance decay | | [`GET /v1/analytics/post-timeline`](https://pozy.ai/docs/api/getPostTimeline) | Get post analytics timeline | | [`GET /v1/analytics/posting-frequency`](https://pozy.ai/docs/api/getPostingFrequency) | Get frequency vs engagement | --- # Tracking and authenticity Watch a post's numbers for a set number of days, read every verified sample, and score whether its views look organic. Regular analytics give you the latest numbers for every post. A **tracking window** goes further for the posts that matter: Pozy watches one post for a fixed period, keeps every reading it takes, does a final read when the period ends and tells you by webhook. On top of those readings it computes an **authenticity score** that flags views that do not look organic. Typical uses are paying creators on verified views (see [Creator campaigns](https://pozy.ai/docs/creators)), proving campaign results to a client, and spotting bought views before you reshare a post. All numbers come from the platform through Pozy's own sync. Nothing is self-reported by the poster. ## Open a tracking window Pass the Pozy `postId`, or an `accountId` plus the post's public `url` for a post published outside Pozy. ```bash cURL curl -X POST https://api.pozy.ai/v1/analytics/tracking \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "url": "https://www.tiktok.com/@acme/video/7431001122334455667", "trackingDays": 30 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/tracking", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6650c1e2a3b4c5d6e7f80a22", url: "https://www.tiktok.com/@acme/video/7431001122334455667", trackingDays: 30, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.window.id, data.window.endsAt); ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/analytics/tracking", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6650c1e2a3b4c5d6e7f80a22", "url": "https://www.tiktok.com/@acme/video/7431001122334455667", "trackingDays": 30, }, timeout=30, ) print(resp.json()["window"]) ``` ```json { "window": { "id": "6650c1e2a3b4c5d6e7f81001", "postId": "6650c1e2a3b4c5d6e7f80e66", "accountId": "6650c1e2a3b4c5d6e7f80a22", "platform": "tiktok", "platformPostId": "7431001122334455667", "startsAt": "2026-10-30T09:00:00.000Z", "endsAt": "2026-11-29T09:00:00.000Z", "status": "open", "finalReadAt": null, "completionReason": null, "requestedBy": "api", "latest": null } } ``` `trackingDays` defaults to 30 and goes up to 90. Opening a window on a post that already has one returns the existing window instead of a second one, so the call is safe to repeat. ## Follow the window | Status | Meaning | | --- | --- | | `open` | Collecting samples on every analytics sync | | `completing` | The period ended and the final read is running | | `completed` | Done. `completionReason` says how it ended | | `cancelled` | You cancelled it. There is no final read and no webhook | | `completionReason` | Meaning | | --- | --- | | `final_read` | The final read succeeded. `latest` holds the closing numbers | | `unreachable` | The sync failed or no sample was ever taken | | `post_deleted` | The post disappeared from the platform | | `cancelled` | Cancelled with `DELETE /v1/analytics/tracking/{windowId}` | `latest` on a window holds the newest verified numbers: `views`, `likes`, `comments`, `shares`, `saves` and the time of that reading. List windows with `GET /v1/analytics/tracking`, filtered by `accountId` and `status`. When the window ends, Pozy sends **`post.tracking_completed`** with the final numbers. Subscribe to it instead of polling the window. ## Read the samples `GET /v1/analytics/samples?postId=...` returns every reading for a post, oldest first, one per analytics sync. Each sample holds the running totals the platform reported at that moment. | Field | Notes | | --- | --- | | `syncedAt` | When the reading was taken | | `views`, `likes`, `comments` | Running totals, including anything from before the window opened | | `shares`, `saves` | `null` (not zero) on platforms without that counter | | `watchSeconds`, `completionRate` | `null` when the platform gives no watch signal | Platform specifics worth knowing: - **Threads:** `shares` counts reposts and quotes, `saves` is `null`. - **Pinterest:** `views` are video views, so an image pin reads 0. `shares` is `null`. - **Facebook:** `views` come from the video or reel itself, `saves` is `null`, and only reels carry watch time. - **X:** `views` are impressions, because X's video view count is shared by every post that carries the same video. Pass `fromDate`, `toDate` and `limit` (up to 2,000) to page through long histories. ## Authenticity score `GET /v1/posts/{postId}/authenticity` returns a score from 0 to 100 computed from the verified samples. Higher looks more organic. ```bash cURL curl https://api.pozy.ai/v1/posts/6650c1e2a3b4c5d6e7f80e66/authenticity \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts/6650c1e2a3b4c5d6e7f80e66/authenticity", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.score, data.confidence, data.reasons); ``` ```python Python resp = requests.get( "https://api.pozy.ai/v1/posts/6650c1e2a3b4c5d6e7f80e66/authenticity", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, timeout=30, ) print(resp.json()) ``` ```json { "postId": "6650c1e2a3b4c5d6e7f80e66", "score": 62, "confidence": 0.8, "reasons": [{ "code": "late_block", "weight": 35 }], "version": "2026-10-29.1", "computedAt": "2026-10-30T03:00:00.000Z", "sampleCount": 14 } ``` Every reason subtracts its `weight` in points from 100: | Code | What it means | | --- | --- | | `late_block` | One large jump in views long after publishing, instead of gradual growth | | `flat_engagement` | Likes, comments and other counters are low for the number of views | | `view_follower_gap` | Views far above the follower count, with no shares to explain them | | `cap_seeking` | Views repeatedly land just under a payout cap | | `prior_flags` | Earlier posts from the same account were flagged | | `no_watch_signal` | No watch time or completion data. Weight 0; it only lowers `confidence` | `confidence` (0 to 1) tells you how much data backs the score: few samples or no watch data means low confidence. `version` names the scoring model, so you can tell when a score was computed under an older one. Scores younger than 30 minutes are served as stored; older ones are recomputed on read. To review an account's posts in bulk, call `GET /v1/analytics/authenticity?accountId=...` with `minScore` or `maxScore` (for example `maxScore=40` for the suspicious ones), plus `from`, `to`, `limit` and `skip`. > **When the webhook fires:** > > `post.authenticity_changed` fires when a tracked post's score crosses 40 in either direction while its window is open. It never fires for a post's first score. The payload carries the new and previous score, the confidence and the reasons. ## Treat the score as a signal The score is evidence, not a verdict. Use it to hold a payout for review or to ask a creator a question, and read the `reasons` before you act. A small account with a genuinely viral post can trip `view_follower_gap` on day one. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/analytics/tracking`](https://pozy.ai/docs/api/openTrackingWindow) | Open a tracking window | | [`GET /v1/analytics/tracking`](https://pozy.ai/docs/api/listTrackingWindows) | List tracking windows | | [`GET /v1/analytics/tracking/{windowId}`](https://pozy.ai/docs/api/getTrackingWindow) | Get a tracking window | | [`DELETE /v1/analytics/tracking/{windowId}`](https://pozy.ai/docs/api/cancelTrackingWindow) | Cancel a tracking window | | [`GET /v1/analytics/samples`](https://pozy.ai/docs/api/getPostSamples) | Get post samples | | [`GET /v1/posts/{postId}/authenticity`](https://pozy.ai/docs/api/getPostAuthenticity) | Get a post's authenticity score | | [`GET /v1/analytics/authenticity`](https://pozy.ai/docs/api/listPostAuthenticity) | List authenticity scores | ## Webhook events | Event | When it fires | | --- | --- | | [`post.tracking_completed`](https://pozy.ai/docs/webhooks/events#post-tracking-completed) | Post tracking completed. | | [`post.authenticity_changed`](https://pozy.ai/docs/webhooks/events#post-authenticity-changed) | Post authenticity changed. | --- # Inbox analytics Measure DM volume, time to first response, busiest hours, message sources and the accounts that get the most conversations. The inbox analytics endpoints turn the message stream behind your [inbox](https://pozy.ai/docs/inbox) into reporting: how many messages came in and went out, how fast your team or your automations answered, when people write, and which accounts carry the load. Every endpoint needs `fromDate` and takes the same filters: `toDate`, `profileId`, `platform` and `accountId`. The date range is at most 365 days. ## Volume `GET /v1/analytics/inbox/volume` returns a summary, a daily series and a per-platform split in one call, enough for a KPI strip and a chart. ```bash cURL curl "https://api.pozy.ai/v1/analytics/inbox/volume?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-10-01&toDate=2026-10-29" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/analytics/inbox/volume?profileId=6650c1e2a3b4c5d6e7f80911&fromDate=2026-10-01&toDate=2026-10-29", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.summary); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/analytics/inbox/volume", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1e2a3b4c5d6e7f80911", "fromDate": "2026-10-01", "toDate": "2026-10-29"}, timeout=30, ) print(resp.json()["summary"]) ``` ```json { "success": true, "from": "2026-10-01", "to": "2026-10-29", "summary": { "received": 1840, "sent": 1622, "read": 1490, "failed": 12, "uniqueConversations": 611 }, "timeseries": [{ "date": "2026-10-01", "received": 58, "sent": 51, "read": 47, "failed": 0 }], "byPlatform": [{ "platform": "instagram", "received": 1210, "sent": 1100, "read": 1032, "failed": 3 }] } ``` ## Response time `GET /v1/analytics/inbox/response-time` pairs every received message with the next message sent in the same conversation and reports the gap. | Field | Meaning | | --- | --- | | `summary.sampleSize` | Conversations that received a message **and** got a reply in the window | | `summary.medianSeconds`, `p90Seconds`, `p99Seconds` | Percentiles of time to first response | | `summary.meanSeconds`, `fastestSeconds`, `slowestSeconds` | The rest of the distribution | | `histogram[]` | Fixed buckets with `lowerSeconds`, `upperSeconds` and `count`, ready for a bar chart | Unanswered conversations are not in `sampleSize`. To get a reply rate, divide `sampleSize` by `summary.uniqueConversations` from the volume endpoint, or compare it with `summary.received`. Report the median and p90 together: a few slow weekends hide behind a good average. ## Busiest hours `GET /v1/analytics/inbox/heatmap` returns day-of-week by hour buckets with `received`, `sent` and `read` counts. Pass `action` (`message.received`, `message.sent`, `message.read` or `all`) to focus on one direction. - `dow` runs from `1` (Monday) to `7` (Sunday). Note this differs from [best time to post](https://pozy.ai/docs/analytics/best-time), where `0` is Monday. - Buckets are sparse: only cells with at least one message come back. Fill the rest with zeros to draw the full 7 by 24 grid. Use it to staff the inbox, or to decide when a [workflow](https://pozy.ai/docs/automations/workflows) should answer instead of a person. ## Where messages come from `GET /v1/analytics/inbox/source-breakdown` splits messages by what produced them: people, workflows, sequences, broadcasts, comment automations, the API, or the platform itself. Each source row has a per-platform sub-split. It tells you how much of the inbox your automations handle. ## Top accounts `GET /v1/analytics/inbox/top-accounts` ranks accounts by message volume, with `received`, `sent`, `conversations`, `repliedCount` and `medianResponseSeconds` per account. `limit` goes up to 50. An account that has been disconnected stays in the list with a placeholder name, so totals still add up. ## Per conversation - `GET /v1/analytics/inbox/conversations` lists conversations with per-row totals (`received`, `sent`, `read`, `failed`, `totalMessages`) and first and last message times. Sort with `sortBy` (`lastMessageAt`, `firstMessageAt`, `totalMessages`, `received`, `sent`, `read`, `failed`) and `order`, and page with `page` and `limit`. - `GET /v1/analytics/inbox/conversations/{conversationId}` returns one conversation's summary, a daily series and its source mix. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/analytics/inbox/volume`](https://pozy.ai/docs/api/getInboxVolume) | Get inbox messaging volume | | [`GET /v1/analytics/inbox/heatmap`](https://pozy.ai/docs/api/getInboxHeatmap) | Get day × hour heatmap | | [`GET /v1/analytics/inbox/source-breakdown`](https://pozy.ai/docs/api/getInboxSourceBreakdown) | Get inbox source breakdown | | [`GET /v1/analytics/inbox/response-time`](https://pozy.ai/docs/api/getInboxResponseTime) | Get inbox response-time stats | | [`GET /v1/analytics/inbox/top-accounts`](https://pozy.ai/docs/api/getInboxTopAccounts) | Get top accounts by inbox volume | | [`GET /v1/analytics/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversationAnalytics) | List conversation analytics | | [`GET /v1/analytics/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/getInboxConversationAnalytics) | Get conversation analytics | --- # Inbox One inbox for DMs, comments, reviews and mentions across every connected account, fed by webhooks and answered through the API. The inbox gathers every conversation and every public reaction to your content into one model. DMs from Instagram, Facebook, X, WhatsApp and the rest become **conversations** with **messages**. Replies under your posts become **comments**. Google and Facebook ratings become **reviews**. Tags of your account elsewhere become **mentions**. You read and answer all of them with the same endpoints, whatever the platform. ## What each platform supports | Platform | DMs | Comments | Reviews | Mentions | | --- | --- | --- | --- | --- | | Instagram | Send and receive, media, quick replies, buttons, cards, reactions | Reply, hide, delete, private reply. Liking is in limited release | – | Comment and caption mentions | | Facebook | Send and receive, media, quick replies, buttons, cards, reactions, message requests | Reply, like, hide, delete, private reply | List and reply | Post and comment mentions (read only) | | X | Send and receive | Reply, like, hide, delete | – | – | | Threads | – | Reply, hide, delete | – | – | | TikTok | Reply only, business accounts | Reply, like, hide, pin, delete your own | – | – | | YouTube | – | Reply, delete, moderate | – | – | | LinkedIn | – | Reply, react, delete (company pages) | – | Company page mentions | | Bluesky | Text only | Reply, like, delete | – | – | | Telegram (coming soon) | Send and receive, media, keyboards, edits | – | – | – | | WhatsApp | Send and receive, templates, interactive messages | – | – | – | | Slack | DMs and bot mentions | – | – | – | | SMS, iMessage, RCS | Send and receive | – | – | – | | Google Business Profile | – | – | List, reply, delete reply | – | | Reddit | Paused | Paused | – | – | Reddit closed its API to third-party apps, so Reddit messages and comments stop syncing until access returns. Telegram and Discord are coming soon and cannot be connected yet; once Discord opens, its bot will send DMs but not read replies (see [Discord](https://pozy.ai/docs/platforms/discord)). Platform pages list the exact limits, for example [Instagram](https://pozy.ai/docs/platforms/instagram) and [WhatsApp](https://pozy.ai/docs/whatsapp). ## How the pieces fit - A **conversation** belongs to one connected account, and the account belongs to a profile. Filter by `profileId` to get one customer's inbox, or by `accountId` for one account. - A **contact** is the person behind one or more conversations, with tags and custom fields. See [Contacts](https://pozy.ai/docs/inbox/contacts). - Every send from automations is attributed: `message.sent` webhooks carry `sentVia` (`human`, `api`, `broadcast`, `sequence`, `workflow`, `comment_automation`, `bulk-api`) plus the ids of what sent it. ## Build on webhooks, not polling The fastest and cheapest inbox is written by webhooks and read from your own database. Polling every conversation burns through your [rate limit](https://pozy.ai/docs/getting-started/rate-limits) and still lags. ### Subscribe once Create one webhook endpoint for `conversation.started`, `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `comment.received` and the `review.*` events. See [Set up webhooks](https://pozy.ai/docs/webhooks). ### Write each event to your store Dedupe on the event id (`X-Pozy-Event-Id`), acknowledge with a `2xx` within 5 seconds and process the event on a worker. `profiles[0].externalId` tells you which of your customers it belongs to when you use [one profile per customer](https://pozy.ai/docs/build). ### Backfill and reconcile When accounts connect, page through `GET /v1/inbox/conversations` with `cursor`, then fetch messages per conversation. Run the same sweep on a schedule to catch anything a missed delivery left behind. ### Send through the API Replies, comments and review answers go out with the endpoints on the following pages. Delivery status comes back on the same webhook stream. > **History from before the connection:** > > When an Instagram or Facebook account connects, Pozy replays the DM history the platform already holds: up to 500 conversations per account and the newest 500 messages in each. The replay runs in the background, arrives already marked as read and fires no webhooks, so read it from the list endpoint. Run your onboarding sweep again about an hour later to pick it up. ## Account errors inside list responses List endpoints aggregate many accounts at once. When one account fails (an expired token, a platform outage), the rest still return. Check `meta`: ```json { "meta": { "accountsQueried": 3, "accountsFailed": 1, "failedAccounts": [{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "platform": "instagram", "error": "The account needs to be reconnected", "code": null, "retryAfter": null }] } } ``` An account in `failedAccounts` usually needs to be reconnected. See [Account health](https://pozy.ai/docs/connect/account-health). ## In this section - [Messages](https://pozy.ai/docs/inbox/messages): List conversations, read messages, reply with text, media and buttons, hand over threads. - [Comments](https://pozy.ai/docs/inbox/comments): Read, reply, hide, like, pin and moderate comments, and DM a commenter. - [Reviews](https://pozy.ai/docs/inbox/reviews): Answer Google Business Profile and Facebook reviews. - [Mentions](https://pozy.ai/docs/inbox/mentions): Mentions of your accounts, X search and engagement. - [Contacts](https://pozy.ai/docs/inbox/contacts): The people behind your conversations, with tags and subscription state. - [Custom fields](https://pozy.ai/docs/inbox/custom-fields): Typed fields on contacts for segments and personalisation. ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. | | [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. | | [`referral.received`](https://pozy.ai/docs/webhooks/events#referral-received) | Referral received. | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | --- # Messages List conversations, read their messages, reply with text, media, buttons and quick replies, start new DMs, and hand a thread to another app. Direct messages live in **conversations**. Each conversation belongs to one connected account and holds the messages between that account and one person (or a group). This page covers reading and answering them. For the overall architecture, start with the [Inbox overview](https://pozy.ai/docs/inbox). ## List conversations ```bash cURL curl "https://api.pozy.ai/v1/inbox/conversations?profileId=6650c1e2a3b4c5d6e7f80911&status=active&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node let cursor: string | undefined; do { const query = new URLSearchParams({ profileId: "6650c1e2a3b4c5d6e7f80911", status: "active", limit: "50" }); if (cursor) query.set("cursor", cursor); const res = await fetch(`https://api.pozy.ai/v1/inbox/conversations?${query}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.data) console.log(c.platform, c.participantName, c.lastMessage); cursor = data.pagination.hasMore ? data.pagination.nextCursor ?? undefined : undefined; } while (cursor); ``` ```python Python import os import requests params = {"profileId": "6650c1e2a3b4c5d6e7f80911", "status": "active", "limit": 50} while True: resp = requests.get( "https://api.pozy.ai/v1/inbox/conversations", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params=params, timeout=30, ) body = resp.json() for c in body["data"]: print(c["platform"], c["participantName"], c["lastMessage"]) if not body["pagination"]["hasMore"]: break params["cursor"] = body["pagination"]["nextCursor"] ``` | Parameter | What it does | | --- | --- | | `profileId`, `accountId`, `platform` | Narrow to a customer, one account or one platform | | `status` | `active` or `archived` | | `folder` | `inbox` (default) or `requests` for message requests, where the platform separates them | | `tags` | Conversations whose contact carries these tags | | `sortOrder` | `desc` (default, newest activity first) or `asc` | | `limit`, `cursor` | Up to 100 per page. Pass `pagination.nextCursor` back as `cursor` | Each conversation carries the participant (`participantName`, `participantPicture`, `participantVerifiedType`), `lastMessage`, `updatedTime`, `unreadCount` and `threadControl`. Conversations that started from an ad carry `adAttribution` with the ad, ad set and campaign names. Instagram conversations can carry `instagramProfile` (`isFollower`, `isFollowing`, `followerCount`, `isVerified`); treat a missing value as unknown, never as "not a follower". To find a conversation by what was said, use [`GET /v1/inbox/conversations/search`](https://pozy.ai/docs/api/searchInboxConversations) with `query` (up to 200 characters) and an optional `direction`. ## Read messages `GET /v1/inbox/conversations/{conversationId}/messages` needs the `accountId` and returns messages oldest first by default (`sortOrder=desc` flips it). Pages hold up to 100 messages. ```json { "id": "6650c1e2a3b4c5d6e7f81201", "conversationId": "6650c1e2a3b4c5d6e7f80b33", "platform": "instagram", "direction": "incoming", "message": "Hi, is the blue one still in stock?", "senderName": "Jane", "createdAt": "2026-10-29T14:00:03.000Z", "attachments": [], "deliveryStatus": null, "reactions": [], "isEdited": false, "isDeleted": false, "sentVia": null } ``` - `direction` is `incoming` or `outgoing`. - `deliveryStatus` on outgoing messages moves through `sent`, `delivered`, `read`, `played` (voice notes) or `failed`, where the platform reports it. `deliveryError` explains a failure. - `attachments[]` have a `type` (`image`, `video`, `audio`, `file`, `sticker`, `share`, `template`), a `url` and, for links that expire, a `refreshUrl`. [`GET .../attachments/{index}`](https://pozy.ai/docs/api/getMessageAttachment) always resolves a fresh link (a redirect by default, or JSON with `format=json`). - Edited and unsent messages keep their history in `editHistory`, `isDeleted` and `deletedAt`. - `sentVia` names what sent an outgoing message: `human`, `api`, `broadcast`, `sequence`, `workflow`, `comment_automation` or `bulk-api`. `null` means unknown, for example a message sent from the platform's own app. ## Reply `POST /v1/inbox/conversations/{conversationId}/messages` sends a message into an existing conversation. `accountId` is required; everything else depends on what you send. ```bash cURL curl -X POST https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9b6c0f4e-1f0b-4b38-9a1e-6f2e8d7f1c55" \ -d '{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "message": "Yes, the blue one is in stock. Want me to hold it?", "quickReplies": [ { "title": "Hold it", "payload": "HOLD_BLUE" }, { "title": "No thanks", "payload": "NO_THANKS" } ] }' ``` ```ts Node import { randomUUID } from "node:crypto"; const res = await fetch("https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6650c1e2a3b4c5d6e7f80a22", message: "Yes, the blue one is in stock. Want me to hold it?", quickReplies: [ { title: "Hold it", payload: "HOLD_BLUE" }, { title: "No thanks", payload: "NO_THANKS" }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data); ``` ```python Python import uuid resp = requests.post( "https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "accountId": "6650c1e2a3b4c5d6e7f80a22", "message": "Yes, the blue one is in stock. Want me to hold it?", "quickReplies": [{"title": "Hold it", "payload": "HOLD_BLUE"}, {"title": "No thanks", "payload": "NO_THANKS"}], }, timeout=30, ) print(resp.json()) ``` A `200` means Pozy accepted the message, not that it was delivered. Delivery arrives later as `message.sent`, then `message.delivered` and `message.read` (or `message.failed`) on platforms that report them. ### What you can send | Field | What it sends | Where | | --- | --- | --- | | `message` | Text | Everywhere | | `attachmentUrl` + `attachmentType` (`image`, `video`, `audio`, `file`) | One attachment from a public HTTPS URL. `attachmentName` sets the file name | Most DM platforms, not Bluesky or Reddit | | `quickReplies` | Up to 13 tappable chips (title up to 20 characters) | Instagram, Facebook | | `buttons` | 1 to 3 `url`, `postback` or `phone` buttons under the text. Text is then capped at 640 characters. `phone` is Facebook only | Instagram, Facebook | | `template` | A `generic` card carousel, up to 10 cards with title, subtitle, image and buttons | Instagram, Facebook | | `interactive` | Lists, call-to-action URLs, flows, product messages, location and contact requests | WhatsApp | | `replyTo` | The platform message id to quote. On Slack it threads the reply instead | WhatsApp, Telegram, Slack | | `replyMarkup` | Inline or reply keyboards | Telegram | | `voiceNote` | Send an OGG/Opus audio attachment as a voice note | WhatsApp | | `location`, `contacts` | A location pin, or contact cards | WhatsApp (location also on iMessage) | | `sharedMediaId`, `sticker` | Share one of the account's own posts, or send the heart sticker, on their own | Instagram | | `subject`, `effect`, `contactCard` | A bold title line, a screen effect, or your contact card | iMessage | | `channel` | Force one send over `sms`, `rcs` or `whatsapp` instead of letting the number pick | iMessage numbers with those add-ons | | `cc` | Extra recipients on the reply | Email threads from connected Google or Microsoft mailboxes | A postback button with a payload of `pozy:workflow:` starts that workflow when tapped. See [Workflows](https://pozy.ai/docs/automations/workflows). Upload a local file first with [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect) (multipart) and send the returned `url` as `attachmentUrl`. ### Messaging windows Platforms limit when a business can write to a person: | Platform | Rule | | --- | --- | | Instagram, Facebook | Free-form replies within 24 hours of the person's last message. Up to 7 days with `messagingType: "MESSAGE_TAG"` and `messageTag: "HUMAN_AGENT"`, for replies typed by a person only | | WhatsApp | Free-form messages within the 24-hour customer service window. Outside it, send an approved template. See [WhatsApp](https://pozy.ai/docs/whatsapp/templates) | | TikTok | Reply only. Up to 10 messages within 48 hours of the person's last message | | X, Bluesky, Telegram | No window, but the person must accept DMs from you | `HUMAN_AGENT` is refused on automated sends (workflows, sequences, broadcasts). The other message tags were retired by Meta and fail. ## Start a conversation `POST /v1/inbox/conversations` opens a new conversation, or appends to the existing one with that person. Name the recipient with `participantId` or `participantUsername`. - **X:** Pozy checks that the person accepts DMs from your account first and answers `422` with code `DM_NOT_ALLOWED` when they do not. Pass `skipDmCheck: true` if you already verified it. X DMs are billed per call ([pricing](https://pozy.ai/docs/billing#x-is-billed-per-call)). - **WhatsApp:** a first message must be a template: pass `templateName`, `templateLanguage`, `templateParams` and, where the template has them, `headerMedia` and `templateButtonParams`. - **TikTok:** businesses cannot start conversations. ## Manage conversations | Task | Call | | --- | --- | | Archive or unarchive | `PUT /v1/inbox/conversations/{id}` with `status: "archived"` or `"active"` | | Mark as read | `POST /v1/inbox/conversations/{id}/read` | | Show "typing..." | `POST /v1/inbox/conversations/{id}/typing` | | React to a message | `POST /v1/inbox/conversations/{id}/messages/{messageId}/reactions` with an `emoji`; `DELETE` to remove | | Edit a sent message | `PATCH /v1/inbox/conversations/{id}/messages/{messageId}` (Telegram text and keyboards) | | Delete a message | `DELETE /v1/inbox/conversations/{id}/messages/{messageId}` where the platform allows unsending | | Label a conversation | Create Page labels with `POST /v1/inbox/labels`, then attach them with `POST .../labels` (Facebook) | | Delete a conversation | `DELETE /v1/inbox/conversations/{id}` | ### Message requests On Facebook, DMs from people the Page has not accepted wait in a requests folder. List them with `folder=requests` and accept one with [`POST .../accept`](https://pozy.ai/docs/api/acceptConversationRequest), which sends your first `message` and moves the thread into the inbox. Instagram does not expose the folder (the list answers `400` with `PLATFORM_LIMITATION`), but accepting still works, and requests arrive as `message.received` once the person writes. ## Hand a conversation to another app On WhatsApp, Facebook and Instagram, another app can own a thread, for example Meta's own inbox app or a business agent. While it does: - Incoming messages still arrive as `message.received`, flagged `metadata.standby: true`. Pozy runs no automation on them. - Your sends answer `409` with code `not_thread_owner`. Move the thread with [`POST /v1/inbox/conversations/{id}/thread-control`](https://pozy.ai/docs/api/setConversationThreadControl): | `action` | Effect | | --- | --- | | `pass` | Give the thread to `targetAppId` (or `target: "ai_agent"`), with an optional `metadata` note | | `take` | Take it back. Meta allows this only to the Page's primary receiver | | `request` | Ask the current owner to pass it. Nothing changes until they do | | `release` | Return it to the primary receiver | `conversation.control_changed` fires whenever the owner changes. On WhatsApp, sending any message takes control back, so a bot that answers every `message.received` must skip standby messages. ## When a send fails | Response | Meaning | What to do | | --- | --- | --- | | `400` `PLATFORM_LIMITATION` | The platform cannot do what you asked, for example an attachment on Bluesky | Send without it | | `409` `not_thread_owner` | Another app owns the thread | Take the thread back first | | `422` `DM_NOT_ALLOWED` | X recipient does not accept your DMs | Do not retry | | `500` | The platform failed the send. It may or may not have gone out | List the conversation's messages before retrying | Send an `Idempotency-Key` with every reply. If the response is lost after a successful send, a retry with the same key returns the original response instead of a second message. A failed (non-2xx) request releases the key, so after an ambiguous `500`, check the messages first; an empty result is inconclusive, not proof it was not sent. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations | | [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation | | [`GET /v1/inbox/conversations/search`](https://pozy.ai/docs/api/searchInboxConversations) | Search conversations | | [`GET /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/getInboxConversation) | Get conversation | | [`PUT /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/updateInboxConversation) | Update conversation status | | [`DELETE /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/deleteInboxConversation) | Delete conversation | | [`GET /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/getInboxConversationMessages) | List messages | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | | [`PATCH /v1/inbox/conversations/{conversationId}/messages/{messageId}`](https://pozy.ai/docs/api/editInboxMessage) | Edit message | | [`DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}`](https://pozy.ai/docs/api/deleteInboxMessage) | Delete message | | [`POST /v1/inbox/conversations/{conversationId}/typing`](https://pozy.ai/docs/api/sendTypingIndicator) | Send typing indicator | | [`POST /v1/inbox/conversations/{conversationId}/thread-control`](https://pozy.ai/docs/api/setConversationThreadControl) | Change who answers a conversation (handover) | | [`POST /v1/inbox/conversations/{conversationId}/accept`](https://pozy.ai/docs/api/acceptConversationRequest) | Accept a message request | | [`GET /v1/inbox/labels`](https://pozy.ai/docs/api/listInboxLabels) | List the Page's conversation labels | | [`POST /v1/inbox/labels`](https://pozy.ai/docs/api/createInboxLabel) | Create a conversation label | | [`DELETE /v1/inbox/labels/{labelId}`](https://pozy.ai/docs/api/deleteInboxLabel) | Delete a conversation label | | [`GET /v1/inbox/conversations/{conversationId}/labels`](https://pozy.ai/docs/api/listConversationLabels) | List the labels on a conversation | | [`POST /v1/inbox/conversations/{conversationId}/labels`](https://pozy.ai/docs/api/addConversationLabel) | Add a label to a conversation | | [`DELETE /v1/inbox/conversations/{conversationId}/labels/{labelId}`](https://pozy.ai/docs/api/removeConversationLabel) | Remove a label from a conversation | | [`POST /v1/inbox/conversations/{conversationId}/read`](https://pozy.ai/docs/api/markConversationRead) | Mark a conversation as read | | [`POST /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions`](https://pozy.ai/docs/api/addMessageReaction) | Add reaction | | [`DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions`](https://pozy.ai/docs/api/removeMessageReaction) | Remove reaction | | [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect) | Upload media file | | [`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}`](https://pozy.ai/docs/api/getMessageAttachment) | Resolve message attachment | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. | | [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. | | [`referral.received`](https://pozy.ai/docs/webhooks/events#referral-received) | Referral received. | --- # Comments Read comments on your posts across platforms, reply, like, hide, pin and moderate them, and send a private DM to a commenter. Comments are the public replies under your posts. Pozy lists them for every connected account that supports them, lets you answer from any of your accounts, and fires `comment.received` the moment a new one arrives. The same endpoints work whether the post was published through Pozy or directly on the platform. ## Find posts with comments `GET /v1/inbox/comments` returns posts that have comments, newest first, with their `commentCount`, `likeCount` and `permalink`. Filter with `profileId`, `accountId`, `platform`, `since` and `minComments`, and sort by `date` or `comments`. > **Do not poll this list:** > > It is cached for up to 10 minutes. For new comments in real time, subscribe to `comment.received` instead and use this list for backfill. Boosted and ad posts from connected Meta ad accounts show up too, flagged `isAd: true` with `adId` and a `placement` of `facebook` or `instagram`. Filter them with `platform=metaads`. ## Read a post's comments ```bash cURL curl "https://api.pozy.ai/v1/inbox/comments/17912345678901234?accountId=6650c1e2a3b4c5d6e7f80a22&limit=25" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/comments/17912345678901234?accountId=6650c1e2a3b4c5d6e7f80a22&limit=25", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.comments) { console.log(c.from.username, c.message, { canHide: c.canHide, canLike: c.canLike }); } ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/inbox/comments/17912345678901234", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6650c1e2a3b4c5d6e7f80a22", "limit": 25}, timeout=30, ) for c in resp.json()["comments"]: print(c["from"]["username"], c["message"]) ``` `postId` is the platform's post id (a YouTube video id on YouTube). Each comment comes with capability flags (`canReply`, `canDelete`, `canHide`, `canLike`) and state (`isHidden`, `isLiked`), so your UI can show only the actions that will work. `from.isOwner` marks comments written by the connected account itself. To page through the replies under one comment, pass `commentId` (Facebook, Instagram, Reddit and TikTok). Bluesky comments carry a `cid` you need for likes and replies. ## Reply `POST /v1/inbox/comments/{postId}` posts a comment. Add `commentId` to reply under a specific comment, leave it out to comment on the post itself. ```bash cURL curl -X POST https://api.pozy.ai/v1/inbox/comments/17912345678901234 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1d2c7a-5a8e-4c43-8f0e-2b6d1a9e7c10" \ -d '{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "commentId": "18034567890123456", "message": "Thanks! The link is in our bio." }' ``` ```ts Node import { randomUUID } from "node:crypto"; const res = await fetch("https://api.pozy.ai/v1/inbox/comments/17912345678901234", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6650c1e2a3b4c5d6e7f80a22", commentId: "18034567890123456", message: "Thanks! The link is in our bio.", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.data.commentId); ``` ```python Python import uuid resp = requests.post( "https://api.pozy.ai/v1/inbox/comments/17912345678901234", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, json={"accountId": "6650c1e2a3b4c5d6e7f80a22", "commentId": "18034567890123456", "message": "Thanks! The link is in our bio."}, timeout=30, ) print(resp.json()) ``` With an `Idempotency-Key`, a retry of the same body within 24 hours replays the first response instead of posting twice. The same key with a different body answers `422`, and a key whose first request is still running answers `409`. Reddit replies need the `subreddit`; Bluesky replies need `parentCid`, `rootUri` and `rootCid` from the thread. ## Moderate and engage | Action | Call | Platforms | | --- | --- | --- | | Hide or unhide | `POST` or `DELETE /v1/inbox/comments/{postId}/{commentId}/hide` | Facebook, Instagram, Threads, X, TikTok | | Delete | `DELETE /v1/inbox/comments/{postId}?accountId=&commentId=` | Facebook, Instagram, Threads, LinkedIn, Bluesky, X, YouTube, TikTok (your own comments), Reddit | | Like or unlike | `POST` or `DELETE .../{commentId}/like` | Facebook, X, Bluesky (needs `cid`), LinkedIn (reactions), Reddit. Instagram is in limited release | | Pin or unpin | `POST` or `DELETE .../{commentId}/pin` | TikTok | | Edit | `PATCH .../{commentId}` with `content` | Reddit | | Moderate | `POST .../{commentId}/moderation` with `published`, `rejected` or `heldForReview`, plus optional `banAuthor` | YouTube, on channels you own | | Like the post | `POST` or `DELETE /v1/inbox/posts/{postId}/like` | LinkedIn, X, Facebook, YouTube, Bluesky | Hidden comments stay visible to the commenter and to page admins, which makes hiding a gentler tool than deleting. On X, you can only hide replies in conversations your account started. LinkedIn reactions take an optional `reactionType` (`LIKE`, `PRAISE`, `EMPATHY`, `INTEREST`, `APPRECIATION`, `ENTERTAINMENT`). Because any connected account can like any post, a brand can have its executives' accounts react to the company post. > **Limited and closed features:** > > - **Instagram likes** need a Meta permission that is still in review. Until it is granted, the call answers `403` with code `PLATFORM_BETA_RESTRICTED` for most accounts. It only ever works for accounts connected through Facebook Login. > - **LinkedIn** accounts connected before the social feed permissions existed answer `403` `linkedin_reconnect_required`. Reconnect them. > - **YouTube** likes on comments do not exist in YouTube's API, and every like on a video spends part of a small daily quota. > - **Reddit** is paused while Reddit blocks third-party apps. ## DM a commenter On Instagram and Facebook you can answer a comment privately: `POST /v1/inbox/comments/{postId}/{commentId}/private-reply` sends a DM to the comment's author. - One private reply per comment, within 7 days of the comment. - Add up to 13 `quickReplies` or 1 to 3 `buttons` (not both). - Instagram can refuse buttons, cards and attachments to people who do not follow the account. That refusal spends the comment's one private reply, so open with plain text when you are not sure. See [Comment to DM](https://pozy.ai/docs/automations/comment-to-dm). To do this automatically on keywords, use a [comment-to-DM automation](https://pozy.ai/docs/automations/comment-to-dm) instead of calling this yourself. ## The `comment.received` event `comment.received` fires for new comments on Instagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit and TikTok. It carries the comment (`id`, `text`, `author`, `isReply`, `parentCommentId`, `createdAt`) and the post it belongs to. A few fields are worth branching on: - `comment.postId` is the Pozy post id, or `null` when the post was published outside Pozy. `platformPostId` is always set. - `comment.author.isOwnAccount` is `true` for comments the connected account wrote, so your bot does not answer itself. - `comment.ad` is present only for comments on paid content, so `if (comment.ad)` separates ad comments from organic ones. - `comment.isLive` marks Instagram comments made during a live broadcast. - `comment.author.instagramProfile` (follower status and count) appears only for people who have messaged the account before. Missing means unknown. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/inbox/comments`](https://pozy.ai/docs/api/listInboxComments) | List commented posts | | [`GET /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/getInboxPostComments) | Get post comments | | [`POST /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/replyToInboxPost) | Reply to comment | | [`DELETE /v1/inbox/comments/{postId}`](https://pozy.ai/docs/api/deleteInboxComment) | Delete comment | | [`PATCH /v1/inbox/comments/{postId}/{commentId}`](https://pozy.ai/docs/api/editInboxComment) | Edit comment | | [`POST /v1/inbox/comments/{postId}/{commentId}/moderation`](https://pozy.ai/docs/api/setCommentModeration) | Set comment moderation status | | [`POST /v1/inbox/comments/{postId}/{commentId}/hide`](https://pozy.ai/docs/api/hideInboxComment) | Hide comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/hide`](https://pozy.ai/docs/api/unhideInboxComment) | Unhide comment | | [`POST /v1/inbox/comments/{postId}/{commentId}/pin`](https://pozy.ai/docs/api/pinInboxComment) | Pin comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/pin`](https://pozy.ai/docs/api/unpinInboxComment) | Unpin comment | | [`POST /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/likeInboxComment) | Like comment | | [`DELETE /v1/inbox/comments/{postId}/{commentId}/like`](https://pozy.ai/docs/api/unlikeInboxComment) | Unlike comment | | [`POST /v1/inbox/posts/{postId}/like`](https://pozy.ai/docs/api/likePost) | Like post | | [`DELETE /v1/inbox/posts/{postId}/like`](https://pozy.ai/docs/api/unlikePost) | Unlike post | | [`POST /v1/inbox/comments/{postId}/{commentId}/private-reply`](https://pozy.ai/docs/api/sendPrivateReplyToComment) | Send private reply | ## Webhook events | Event | When it fires | | --- | --- | | [`comment.received`](https://pozy.ai/docs/webhooks/events#comment-received) | Comment received. | --- # Reviews List Google Business Profile and Facebook reviews in one feed, filter by rating, reply, and react to new, edited and removed reviews by webhook. Reviews from Google Business Profile locations and Facebook Pages land in one list. You can filter it by rating and reply status, answer from the API, and get a webhook the moment a Google review is posted, edited or removed. ## List reviews ```bash cURL curl "https://api.pozy.ai/v1/inbox/reviews?profileId=6650c1e2a3b4c5d6e7f80911&maxRating=3&hasReply=false" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/reviews?profileId=6650c1e2a3b4c5d6e7f80911&maxRating=3&hasReply=false", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const r of data.data) { console.log(r.platform, r.locationName, r.rating, r.text); } ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/inbox/reviews", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1e2a3b4c5d6e7f80911", "maxRating": 3, "hasReply": "false"}, timeout=30, ) for r in resp.json()["data"]: print(r["platform"], r["rating"], r["text"]) ``` ```json { "id": "accounts/1023/locations/4567/reviews/AbCdEf123", "platform": "googlebusiness", "accountId": "6650c1e2a3b4c5d6e7f80a22", "locationName": "Acme Coffee, Gran Via", "reviewer": { "id": null, "name": "Marta P.", "profileImage": null }, "rating": 2, "text": "Great coffee, but we waited 20 minutes.", "created": "2026-10-28T18:42:00Z", "hasReply": false, "hasPhotos": false, "reply": null, "reviewUrl": "https://maps.google.com/?cid=1234567890" } ``` | Parameter | What it does | | --- | --- | | `profileId`, `accountId` | One customer profile or one location/Page | | `platform` | `googlebusiness` or `facebook` | | `minRating`, `maxRating` | Star range, 1 to 5 | | `hasReply` | `false` gives you the queue still waiting for an answer | | `sortBy`, `sortOrder` | `date` (default) or `rating`, `desc` or `asc` | | `limit`, `cursor` | Up to 50 per page | Facebook recommendations have no stars: they come with `recommendationType` set to `positive` or `negative` instead of a numeric `rating`. Each account's reviews are read from the platform within a fixed time budget per request and cached for up to a minute, so a location with thousands of reviews can come back truncated. Use the webhooks below to stay current and the list to backfill. ## Reply ```bash cURL curl -X POST "https://api.pozy.ai/v1/inbox/reviews/accounts%2F1023%2Flocations%2F4567%2Freviews%2FAbCdEf123/reply" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6a2e9b10-77f1-4b6a-9d3c-0c5e8f2a4b71" \ -d '{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "message": "Thanks Marta, and sorry about the wait. We have added a second barista at lunchtime." }' ``` ```ts Node import { randomUUID } from "node:crypto"; // Google review ids contain slashes: encode the id so it stays one path segment const reviewId = encodeURIComponent("accounts/1023/locations/4567/reviews/AbCdEf123"); const res = await fetch(`https://api.pozy.ai/v1/inbox/reviews/${reviewId}/reply`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6650c1e2a3b4c5d6e7f80a22", message: "Thanks Marta, and sorry about the wait. We have added a second barista at lunchtime.", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python import os, uuid from urllib.parse import quote import requests review_id = "accounts/1023/locations/4567/reviews/AbCdEf123" resp = requests.post( f"https://api.pozy.ai/v1/inbox/reviews/{quote(review_id, safe='')}/reply", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, json={"accountId": "6650c1e2a3b4c5d6e7f80a22", "message": "Thanks Marta, and sorry about the wait."}, timeout=30, ) print(resp.json()) ``` Google review ids contain slashes, so URL-encode them in the path. To remove a reply, call `DELETE /v1/inbox/reviews/{reviewId}/reply` with the `accountId`; that works on Google Business Profile only. `Idempotency-Key` works as for comments: the same key and body within 24 hours replays the first response, a different body answers `422`. > **Write replies people will read:** > > Answer the specific complaint, name what changes, and keep it short. Public replies are part of the listing that the next customer reads. ## Review webhooks Google Business Profile reviews are reported in real time: | Event | When | | --- | --- | | `review.new` | A new review was posted | | `review.updated` | The reviewer edited text or rating, or a reply was added (through Pozy or Google's own tools) | | `review.deleted` | Google stopped returning the review, for example after its spam filter removed it | | `review.restored` | A review reported as deleted came back | `review.deleted` is detected by checking, so it usually arrives 6 to 12 hours after the review disappears, and only for locations covered by a webhook subscribed to `review.deleted` or `review.restored`. It can name a review you never received `review.new` for; ignore ids you do not know. ## Google-specific management For one location's reviews with Google's own paging and batch reads, use the [Google Business Reviews](https://pozy.ai/docs/google-business/reviews) endpoints under `/v1/accounts/{accountId}/gmb-reviews`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/inbox/reviews`](https://pozy.ai/docs/api/listInboxReviews) | List reviews | | [`POST /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToInboxReview) | Reply to review | | [`DELETE /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteInboxReviewReply) | Delete review reply | ## Webhook events | Event | When it fires | | --- | --- | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | --- # Mentions See where other people tag your accounts on Instagram, Facebook and LinkedIn, reply to Instagram mentions, and search and engage on X. Mentions are posts and comments by other people that tag one of your accounts. They do not show up under your own posts, so they are easy to miss. Pozy collects them into one list, and adds search and engagement tools for X. ## List mentions `GET /v1/inbox/mentions` returns mentions of your connected accounts from the last 7 days, newest first. ```bash cURL curl "https://api.pozy.ai/v1/inbox/mentions?profileId=6650c1e2a3b4c5d6e7f80911&limit=25" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/mentions?profileId=6650c1e2a3b4c5d6e7f80911&limit=25", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const m of data.data) console.log(m.platform, m.type, m.authorUsername, m.content); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/inbox/mentions", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "6650c1e2a3b4c5d6e7f80911", "limit": 25}, timeout=30, ) for m in resp.json()["data"]: print(m["platform"], m["type"], m["content"]) ``` Each mention has a `platform`, a `type` (`caption`, `post` or `comment`), the author, the text, a `permalink`, and the ids you need to answer it (`mediaId`, `commentId`, `postId`). | Platform | What is collected | Can you reply here? | | --- | --- | --- | | Instagram | Someone tags the account in a comment on their own media, or in a caption. Accounts connected through Facebook Login only | Yes | | Facebook | Someone tags the Page in a post or a comment | No. Open the `permalink` | | LinkedIn | Mentions of a company page | No | Comments on your own posts are never listed as mentions, even when they contain your handle; they are in [Comments](https://pozy.ai/docs/inbox/comments). On Instagram the text is fetched in the background after the first request, so `content` can be empty on the first read. ## Reply to an Instagram mention `POST /v1/inbox/mentions/reply` answers in one of two ways, depending on whether you pass `commentId`: - **Comment mention** (you were tagged inside a comment): send `mediaId` and `commentId`. Your reply goes under that comment. - **Caption mention** (you were tagged in the caption): send `mediaId` only. Your reply is posted as a comment on their media. ```bash curl -X POST https://api.pozy.ai/v1/inbox/mentions/reply \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6650c1e2a3b4c5d6e7f80a22", "mediaId": "17899887766554433", "message": "Thanks for the shout-out!" }' ``` Instagram's API does not support replying to story mentions. To answer those with a DM, use a [comment automation](https://pozy.ai/docs/automations/comment-to-dm) with the `story_mention` trigger or a [workflow](https://pozy.ai/docs/automations/workflows). ## Tagged posts Tags in photos are not the same as @mentions. Read them separately: - [`GET /v1/accounts/{accountId}/instagram/tags`](https://pozy.ai/docs/api/listInstagramTaggedMedia) lists media the Instagram account is tagged in. - [`GET /v1/accounts/{accountId}/facebook-tagged-posts`](https://pozy.ai/docs/api/listFacebookTaggedPosts) lists posts a Page is tagged in, and [`facebook-visitor-posts`](https://pozy.ai/docs/api/listFacebookVisitorPosts) lists posts visitors left on the Page. - To open one Instagram caption or comment mention by id, use [`instagram/mentions/media/{mediaId}`](https://pozy.ai/docs/api/getInstagramMentionedMedia) or [`instagram/mentions/comments/{commentId}`](https://pozy.ai/docs/api/getInstagramMentionedComment). ## Mention someone on LinkedIn To tag a person or company in a LinkedIn post, put their profile or company URL inside `@[...]` in the post content, for example `@[https://www.linkedin.com/company/acme]`. Pozy resolves it to LinkedIn's own name when the post publishes. To preview the result, or get ready-made markup, call [`GET /v1/accounts/{accountId}/linkedin-mentions?url=...`](https://pozy.ai/docs/api/getLinkedInMentions). It returns the URN and a `mentionFormat` such as `@[Acme](urn:li:organization:123)` you can paste into content. Resolving a person's profile URL needs the connected member to administer at least one company page. ## X: search and engagement X has no mentions feed through this list. Use search instead, then act on what you find from any connected X account. | Task | Call | | --- | --- | | Search recent posts (last 7 days, X search operators) | `GET /v1/twitter/search?accountId=&query=` | | Look up one post by id | `GET /v1/twitter/tweet?accountId=&tweetId=` | | Repost, undo | `POST` / `DELETE /v1/twitter/retweet` with `accountId` and `tweetId` | | Bookmark, remove | `POST` / `DELETE /v1/twitter/bookmark` with `accountId` and `tweetId` | | Follow, unfollow | `POST` / `DELETE /v1/twitter/follow` with `accountId` and `targetUserId` | ```bash curl "https://api.pozy.ai/v1/twitter/search?accountId=6650c1e2a3b4c5d6e7f80a33&query=%40acme%20-is%3Aretweet&limit=25" \ -H "Authorization: Bearer $POZY_API_KEY" ``` - X allows 50 requests per 15 minutes for each of repost, bookmark and follow. Reposts also count against X's posting limit. - Bookmarks and follows need the matching X permissions. A `403` usually means the account must be reconnected to grant them. - Following a protected account returns `pending_follow: true` until they accept. - Every X read and write is billed per call. See [Pricing](https://pozy.ai/docs/billing). ## Reddit search Reddit search, subreddit feeds and post comments (`/v1/reddit/*`) answer `503` with `PLATFORM_DISABLED` while Reddit blocks third-party apps. Reddit Ads are not affected. See [Reddit](https://pozy.ai/docs/platforms/reddit). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/linkedin-mentions`](https://pozy.ai/docs/api/getLinkedInMentions) | Resolve LinkedIn mention | | [`GET /v1/inbox/mentions`](https://pozy.ai/docs/api/listInboxMentions) | List mentions | | [`POST /v1/inbox/mentions/reply`](https://pozy.ai/docs/api/replyToMention) | Reply to a mention | | [`GET /v1/reddit/search`](https://pozy.ai/docs/api/searchReddit) | Search posts | | [`GET /v1/reddit/feed`](https://pozy.ai/docs/api/getRedditFeed) | Get subreddit feed | | [`GET /v1/reddit/comments/{postId}`](https://pozy.ai/docs/api/getRedditPostComments) | Get the comments of a Reddit post | | [`POST /v1/twitter/retweet`](https://pozy.ai/docs/api/retweetPost) | Retweet a post | | [`DELETE /v1/twitter/retweet`](https://pozy.ai/docs/api/undoRetweet) | Undo retweet | | [`POST /v1/twitter/bookmark`](https://pozy.ai/docs/api/bookmarkPost) | Bookmark a tweet | | [`DELETE /v1/twitter/bookmark`](https://pozy.ai/docs/api/removeBookmark) | Remove bookmark | | [`POST /v1/twitter/follow`](https://pozy.ai/docs/api/followUser) | Follow a user | | [`DELETE /v1/twitter/follow`](https://pozy.ai/docs/api/unfollowUser) | Unfollow a user | | [`GET /v1/twitter/search`](https://pozy.ai/docs/api/searchTweets) | Search recent tweets | | [`GET /v1/twitter/tweet`](https://pozy.ai/docs/api/getTweet) | Look up a tweet | --- # Contacts Keep one record per person across your messaging channels, with tags, notes and subscription state, and use it to target broadcasts and sequences. A **contact** is a person you talk to. One contact can have several **channels**: their Instagram DMs, their WhatsApp number and their Telegram chat can all point at the same record. Contacts carry tags, notes, a subscription flag and [custom fields](https://pozy.ai/docs/inbox/custom-fields), and they are what [broadcasts](https://pozy.ai/docs/automations/broadcasts) and [sequences](https://pozy.ai/docs/automations/sequences) target. Contacts belong to a profile. Pozy creates them as people message your accounts; you can also create and import them yourself. ## Create a contact Create the record and, optionally, its first channel in the same call by passing `accountId`, `platform` and `platformIdentifier` (the person's id on that platform; a phone number for WhatsApp and SMS). ```bash cURL curl -X POST https://api.pozy.ai/v1/contacts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "name": "Marta Pons", "email": "marta@example.com", "tags": ["vip", "newsletter"], "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "platformIdentifier": "+34600111222" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/contacts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", name: "Marta Pons", email: "marta@example.com", tags: ["vip", "newsletter"], accountId: "6650c1e2a3b4c5d6e7f80a44", platform: "whatsapp", platformIdentifier: "+34600111222", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.contact.id, data.channel?.id); ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/contacts", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "6650c1e2a3b4c5d6e7f80911", "name": "Marta Pons", "email": "marta@example.com", "tags": ["vip", "newsletter"], "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "platformIdentifier": "+34600111222", }, timeout=30, ) print(resp.json()["contact"]["id"]) ``` `isSubscribed` defaults to `true`. If something about the request was off but the contact was still created (for example a channel that could not be attached), the response carries a `warning`. ## Import in bulk `POST /v1/contacts/bulk` imports up to 1,000 contacts per call into one profile. Duplicates are skipped and any new tags are merged onto the existing contact. ```json { "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a44", "contacts": [ { "name": "Marta Pons", "platformIdentifier": "+34600111222", "tags": ["spring-2026"] }, { "name": "Leo Martin", "platformIdentifier": "+34600333444", "email": "leo@example.com" } ] } ``` ```json { "success": true, "created": 2, "skipped": 0, "errors": [], "total": 2 } ``` - `accountId` is required whenever contacts carry a `platformIdentifier`, and then every row needs one. - On WhatsApp and SMS, identifiers are normalised to digits. A value that is not a phone number is rejected for that row only. - Bad rows are reported in `errors` with HTTP `200`; the rest of the import still goes through. Check `errors` on every call. ## Find contacts `GET /v1/contacts` lists a profile's contacts. Filter with `search` (name, email and company), `tag` or `tags`, `platform`, `accountId` and `isSubscribed`, and page with `limit` (up to 200) and `skip`. Each contact includes its tags, custom field values, notes, message counters (`messagesSentCount`, `messagesReceivedCount`) and the last time a message went each way. `GET /v1/contacts/{id}/channels` lists every channel of one contact, with the conversation id for each. ## Update and tag `PATCH /v1/contacts/{contactId}` changes only the fields you send: `name`, `email`, `company`, `avatarUrl`, `tags`, `isSubscribed`, `isBlocked` and `notes`. `tags` replaces the whole list. To add one tag, read the contact, append, and send the full list back. Workflows can tag contacts for you with their `add_tag` and `remove_tag` nodes; see [Workflow nodes](https://pozy.ai/docs/automations/workflow-nodes). ## Opt-in and blocking | Field | Effect | | --- | --- | | `isSubscribed: false` | Broadcast segments that filter on `isSubscribed: true` leave the contact out, and sequences with `exitOnUnsubscribe` (on by default) end for them | | `isBlocked: true` | Marks the person as blocked in your records | Respect opt-outs on every channel. For WhatsApp, SMS and RCS, the platforms and carriers require prior consent before you send promotional messages. See [WhatsApp](https://pozy.ai/docs/whatsapp) and [SMS](https://pozy.ai/docs/telephony/sms). ## Delete `DELETE /v1/contacts/{contactId}` permanently deletes the contact and all its channels. It cannot be undone. ## Contact webhooks | Event | Fires when | | --- | --- | | `contact.updated` | A contact's details changed | | `contact.deleted` | A contact was deleted | | `contact.tag_added`, `contact.tag_removed` | A tag was added or removed, by the API, a workflow node or a tracked link click in a comment automation | | `contact.field_changed` | A custom field changed value | Tag and field events fire once per real change. Re-adding a tag the contact already has, or writing a field's current value, fires nothing. Creating a contact with tags fires nothing either; only changes to existing contacts do. The payload's `source` says who made the change: `api` (the API or the dashboard), `workflow` or `automation`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/contacts`](https://pozy.ai/docs/api/listContacts) | List contacts | | [`POST /v1/contacts`](https://pozy.ai/docs/api/createContact) | Create contact | | [`GET /v1/contacts/{contactId}`](https://pozy.ai/docs/api/getContact) | Get contact | | [`PATCH /v1/contacts/{contactId}`](https://pozy.ai/docs/api/updateContact) | Update contact | | [`DELETE /v1/contacts/{contactId}`](https://pozy.ai/docs/api/deleteContact) | Delete contact | | [`GET /v1/contacts/{contactId}/channels`](https://pozy.ai/docs/api/getContactChannels) | List channels for a contact | | [`POST /v1/contacts/bulk`](https://pozy.ai/docs/api/bulkCreateContacts) | Bulk create contacts | ## Webhook events | Event | When it fires | | --- | --- | | [`contact.updated`](https://pozy.ai/docs/webhooks/events#contact-updated) | Contact updated. | | [`contact.deleted`](https://pozy.ai/docs/webhooks/events#contact-deleted) | Contact deleted. | | [`contact.tag_added`](https://pozy.ai/docs/webhooks/events#contact-tag-added) | Contact tag added. | | [`contact.tag_removed`](https://pozy.ai/docs/webhooks/events#contact-tag-removed) | Contact tag removed. | | [`contact.field_changed`](https://pozy.ai/docs/webhooks/events#contact-field-changed) | Contact field changed. | --- # Custom fields Define typed fields on contacts, such as plan, city or renewal date, set them from your app or a workflow, and use them in segments and messages. Custom fields add your own data to [contacts](https://pozy.ai/docs/inbox/contacts): a customer's plan, their city, the date their trial ends. You define a field once per profile, with a type, then set values per contact. Fields feed broadcast segments, workflow conditions and message personalisation. ## Define a field ```bash cURL curl -X POST https://api.pozy.ai/v1/custom-fields \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "name": "Plan", "slug": "plan", "type": "select", "options": ["free", "pro", "team"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/custom-fields", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", name: "Plan", slug: "plan", type: "select", options: ["free", "pro", "team"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.field.id, data.field.slug); ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/custom-fields", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"profileId": "6650c1e2a3b4c5d6e7f80911", "name": "Plan", "slug": "plan", "type": "select", "options": ["free", "pro", "team"]}, timeout=30, ) print(resp.json()["field"]) ``` | Field | Notes | | --- | --- | | `profileId` | Required. Fields belong to one profile | | `name` | Required. Shown in the dashboard | | `slug` | The key you use in the API and in templates. Generated from the name when you leave it out | | `type` | Required: `text`, `number`, `date`, `boolean` or `select` | | `options` | The allowed values for `select` fields | The type is fixed once the field exists. `PATCH /v1/custom-fields/{fieldId}` can rename it or change `options`, not the type. To change the type, create a new field and copy the values. ## Set and clear values `PUT /v1/contacts/{contactId}/fields/{slug}` sets or overwrites one value. The value must match the field's type: a number for `number`, `true` or `false` for `boolean`, an ISO date for `date`, one of the `options` for `select`. ```bash cURL curl -X PUT https://api.pozy.ai/v1/contacts/6650c1e2a3b4c5d6e7f80c44/fields/plan \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "value": "pro" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/contacts/6650c1e2a3b4c5d6e7f80c44/fields/plan", { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ value: "pro" }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.put( "https://api.pozy.ai/v1/contacts/6650c1e2a3b4c5d6e7f80c44/fields/plan", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"value": "pro"}, timeout=30, ) ``` `DELETE /v1/contacts/{contactId}/fields/{slug}` clears the value. Deleting the field definition with `DELETE /v1/custom-fields/{fieldId}` removes its values from every contact. Values come back on every contact under `customFields`, keyed by slug. ## Where fields are used | Where | How | | --- | --- | | Broadcast segments | `segmentFilters.customFields` matches contacts holding exact values, for example `{ "plan": "pro" }`. Types must match too: `5` does not match `"5"`. See [Broadcasts](https://pozy.ai/docs/automations/broadcasts) | | Workflows | `{{contact.fields.plan}}` in any message, `condition` rules on it, and `set_field` nodes to write it. See [Workflow nodes](https://pozy.ai/docs/automations/workflow-nodes) | ## Field changes as webhooks `contact.field_changed` fires whenever a value actually changes, with `field`, `previousValue`, `value` and `source` (`api`, `workflow` or `automation`). Writing the value a contact already has fires nothing. Use it to mirror fields into your CRM without polling. ## Endpoints | Endpoint | What it does | | --- | --- | | [`PUT /v1/contacts/{contactId}/fields/{slug}`](https://pozy.ai/docs/api/setContactFieldValue) | Set custom field value | | [`DELETE /v1/contacts/{contactId}/fields/{slug}`](https://pozy.ai/docs/api/clearContactFieldValue) | Clear custom field value | | [`GET /v1/custom-fields`](https://pozy.ai/docs/api/listCustomFields) | List custom field definitions | | [`POST /v1/custom-fields`](https://pozy.ai/docs/api/createCustomField) | Create custom field | | [`PATCH /v1/custom-fields/{fieldId}`](https://pozy.ai/docs/api/updateCustomField) | Update custom field | | [`DELETE /v1/custom-fields/{fieldId}`](https://pozy.ai/docs/api/deleteCustomField) | Delete custom field | ## Webhook events | Event | When it fires | | --- | --- | | [`contact.field_changed`](https://pozy.ai/docs/webhooks/events#contact-field-changed) | Contact field changed. | --- # Automations Answer comments with DMs, run conversation workflows, send drip sequences and broadcasts, and set up the prompts people see when they open a chat. Automations act on your messaging accounts without someone at the keyboard. Pozy has five building blocks. They share [contacts](https://pozy.ai/docs/inbox/contacts), tags and [custom fields](https://pozy.ai/docs/inbox/custom-fields), so a tag set by one is visible to the others. | You want to | Use | Platforms | | --- | --- | --- | | DM everyone who comments a keyword under a post | [Comment to DM](https://pozy.ai/docs/automations/comment-to-dm) | Instagram, Facebook (public replies on TikTok, Threads, LinkedIn, YouTube) | | Run a conversation: ask questions, branch on answers, call your backend, hand off to a person | [Workflows](https://pozy.ai/docs/automations/workflows) | WhatsApp, Instagram, Facebook, X, Bluesky, TikTok | | Send a series of messages over days to people who opted in | [Sequences](https://pozy.ai/docs/automations/sequences) | WhatsApp, Instagram, Facebook, X, Bluesky, Slack | | Send one message to many contacts at once, now or later | [Broadcasts](https://pozy.ai/docs/automations/broadcasts) | WhatsApp, SMS, Instagram, Facebook, X, Bluesky, Slack, iMessage | | Show prompts, menus and a greeting when someone opens a chat | [Conversation starters](https://pozy.ai/docs/automations/conversation-starters) | Facebook, Instagram, WhatsApp | ## How they connect ```text Comment "guide" on a reel -> comment automation sends a DM with a button (comment to DM) -> tap starts a workflow: asks for an email, tags (workflows) -> workflow enrolls the contact in a 3-step sequence (sequences) -> later, a product launch reaches every "guide" tag (broadcasts) ``` - A button or menu item whose payload is `pozy:workflow:` starts that workflow when tapped. It works from comment automation DMs, persistent menus, ice breakers and the Get Started button. - A workflow's `enroll_sequence` node puts the contact into a sequence. - `add_tag`, `remove_tag` and `set_field` nodes write to the contact, and broadcasts target contacts by those tags and fields. ## Rules every automation follows **Platform messaging windows apply.** On Instagram and Facebook, automated messages can only go out within 24 hours of the person's last message. The `HUMAN_AGENT` tag that extends the window is for replies typed by a person and is rejected on automated sends. On WhatsApp, anything outside the 24-hour window must be an approved template. See [Messages](https://pozy.ai/docs/inbox/messages#messaging-windows). **Automations answer before workflows.** When an active comment automation matches a comment, it answers and a comment-triggered workflow does not start. The same goes for story replies and story mentions. **A thread owned by another app is left alone.** Messages that arrive while another app owns the conversation (`metadata.standby: true`) start nothing. See [handover](https://pozy.ai/docs/inbox/messages#hand-a-conversation-to-another-app). **Every send is attributed.** `message.sent` webhooks carry `sentVia` (`workflow`, `sequence`, `broadcast`, `comment_automation`) plus `workflowId` and `executionId`, `sequenceId`, `broadcastId` or `automationId`. Use them to credit replies and conversions to the automation that earned them. **Outbound messages are metered.** Messages sent by automations count toward your outbound messages, with a free monthly allowance. See [Pricing](https://pozy.ai/docs/billing). ## Webhook events | Event | When it fires | | --- | --- | | [`contact.tag_added`](https://pozy.ai/docs/webhooks/events#contact-tag-added) | Contact tag added. | | [`contact.tag_removed`](https://pozy.ai/docs/webhooks/events#contact-tag-removed) | Contact tag removed. | | [`contact.field_changed`](https://pozy.ai/docs/webhooks/events#contact-field-changed) | Contact field changed. | | [`sequence.enrolled`](https://pozy.ai/docs/webhooks/events#sequence-enrolled) | Sequence enrolled. | | [`sequence.exited`](https://pozy.ai/docs/webhooks/events#sequence-exited) | Sequence exited. | | [`workflow.run.started`](https://pozy.ai/docs/webhooks/events#workflow-run-started) | Workflow run started. | | [`workflow.run.completed`](https://pozy.ai/docs/webhooks/events#workflow-run-completed) | Workflow run completed. | | [`workflow.run.failed`](https://pozy.ai/docs/webhooks/events#workflow-run-failed) | Workflow run failed. | --- # Comment to DM Send a DM, a public reply or both when someone comments a keyword, replies to a story or mentions you, with follower rules, link tracking and repeat limits. A comment automation watches one post (or every post on an account) for a keyword and answers each matching comment automatically: a private DM to the commenter, a public reply under the comment, or both. It is the classic "comment GUIDE and I'll send it to you" funnel, without a graph to build. | Platform | What the automation does | | --- | --- | | Instagram | DM (with buttons, quick replies, a product card or media), public reply, like or hide the comment, follower rules. Also story replies, story mentions and live comments | | Facebook | DM, public reply, like or hide the comment | | TikTok, Threads, LinkedIn, YouTube | Public reply only. These platforms have no private reply to a comment | X accounts are refused with `400` `platform_not_supported`. ## Create one This automation answers anyone who comments "guide" under one reel with a DM and a button, then thanks them publicly. ```bash cURL curl -X POST https://api.pozy.ai/v1/comment-automations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a22", "name": "Guide giveaway", "platformPostId": "17912345678901234", "keywords": ["guide"], "matchMode": "word", "dmMessage": "Hey {{first_name}}! Here is the guide you asked for.", "buttons": [{ "type": "url", "title": "Get the guide", "url": "https://acme.example/guide.pdf" }], "commentReply": "Sent it to your DMs, {{first_name}}!", "commentReplyVariations": ["Check your inbox!", "On its way to your DMs."], "actions": { "likeComment": true } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/comment-automations", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", accountId: "6650c1e2a3b4c5d6e7f80a22", name: "Guide giveaway", platformPostId: "17912345678901234", keywords: ["guide"], matchMode: "word", dmMessage: "Hey {{first_name}}! Here is the guide you asked for.", buttons: [{ type: "url", title: "Get the guide", url: "https://acme.example/guide.pdf" }], commentReply: "Sent it to your DMs, {{first_name}}!", commentReplyVariations: ["Check your inbox!", "On its way to your DMs."], actions: { likeComment: true }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.automation.id); ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/comment-automations", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a22", "name": "Guide giveaway", "platformPostId": "17912345678901234", "keywords": ["guide"], "matchMode": "word", "dmMessage": "Hey {{first_name}}! Here is the guide you asked for.", "buttons": [{"type": "url", "title": "Get the guide", "url": "https://acme.example/guide.pdf"}], "commentReply": "Sent it to your DMs, {{first_name}}!", "actions": {"likeComment": True}, }, timeout=30, ) print(resp.json()["automation"]["id"]) ``` The automation is active as soon as it is created. Send `isActive: false` to create it paused, and `PATCH /v1/comment-automations/{id}` to change it later. ## Choose what triggers it | `trigger` | Fires on | Notes | | --- | --- | --- | | `comment` (default) | A keyword comment on a post or reel | Every platform | | `live_comment` | A keyword comment during one of your Instagram live broadcasts | Answered with a private reply while live | | `story_reply` | A keyword reply to your Instagram story | `platformPostId` is the story id; omit it for any story | | `story_mention` | Someone mentions your Instagram account in their story | Mentions carry no text, so leave `keywords` empty | **Which post.** Pass `platformPostId` for one live post, or leave it out to watch every post on the account. To prepare an automation for a post you have scheduled but not published, pass the Pozy `postId` instead: the automation waits and arms itself when the post goes live. **Which comments.** | Field | Effect | | --- | --- | | `keywords` | Words that trigger it. An empty list matches every comment | | `matchMode` | `contains` (default) matches anywhere, even inside another word ("app" fires on "happy"). `word` matches whole words only. `exact` needs the whole comment to be the keyword | | `excludeKeywords` | Comments containing one of these never trigger, even if a keyword matches | | `typoTolerance` | With `word` mode, also fire on close misspellings: one typo for keywords of 4 to 7 letters, two from 8 letters | | `alsoMatchInDms` | Also answer people who send the keyword as a DM instead of commenting it. Needs at least one keyword | Use `word` mode for short keywords. `contains` with a short keyword fires on far more comments than you expect. ## What the commenter gets | Field | Notes | | --- | --- | | `dmMessage` | Required on Instagram and Facebook. Up to 640 characters when it has buttons, about 1,000 otherwise. Rejected on reply-only platforms | | `dmMessageVariations` | Up to 5 alternative texts. Each comment gets one at random, so repeat commenters do not see identical messages | | `buttons` | 1 to 3 `url`, `postback` or `phone` buttons (titles up to 20 characters, `phone` on Facebook only) | | `template` | A product card or a carousel of up to 10 cards, sent instead of the button bubble. Instagram shows cards in the mobile app only | | `quickReplies` | Up to 13 chips. Not with `buttons` or `template`. Chips do not render in the message requests folder, where a first DM to a stranger lands, so prefer buttons for first contact | | `dmMedia` | An image, video, audio clip or file, sent as a second message | | `commentReply`, `commentReplyVariations` | The public reply under the comment. Required on reply-only platforms | Personalise any text with `{{first_name}}`, `{{name}}` and `{{username}}`. Missing values become an empty string, so write copy that still reads well without them. **Link tracking.** URL buttons are wrapped in a tracked redirect by default, so you can count clicks in the logs. Add `clickTag` to tag every contact who clicks. Send `linkTracking: false` to keep links exactly as written. **Continue into a workflow.** A postback button with payload `pozy:workflow:` starts that workflow on tap, skipping its keyword condition. The workflow must be active on the same account and profile. The DM alone does not start it; the person has to tap. See [Workflows](https://pozy.ai/docs/automations/workflows). ## Timing and the public reply - `dmDelaySeconds` (up to 86,400) waits before sending the DM. The comment is still matched and deduplicated the moment it arrives. - `commentReplyDelaySeconds` waits before the public reply. The public reply never goes out before the DM. - `publicReplyPolicy`: `after_dm` (default) posts the public reply only after a successful DM. `always` posts it whatever happens to the DM. - `actions.likeComment` and `actions.hideComment` like or hide the matched comment. They never block the DM, and when one cannot run, the log says why. Liking on Instagram is in limited release; see [Comments](https://pozy.ai/docs/inbox/comments#moderate-and-engage). ## Follower rules (Instagram) `audience` limits who gets the DM: | Field | Values | | --- | --- | | `followerStatus` | `any` (default), `follower`, `non_follower` | | `minFollowerCount` | Skip commenters with fewer followers than this | | `whenUnknown` | `send` (default), `skip` or `verify` | | `tapToUnlock` | Send everyone a confirm button first, and the real DM when they tap. No follow check at all | Instagram only reveals whether someone follows you after they have messaged your account, and commenting does not count. So for most first-time commenters the answer is unknown, and `whenUnknown` decides: - `send` delivers the DM anyway, so no real customer is silently dropped. - `skip` stays silent. - `verify` sends `followGate.message` with a confirm button. The tap is itself a message, which lets Pozy check the follow, then send the real DM or `followGate.notFollowingMessage`. `followGate` sets the copy: `message`, `buttonLabel`, an optional `profileButtonLabel` that opens your profile, and `notFollowingMessage`. > **One private reply per comment:** > > Instagram allows a single private reply per comment, within 7 days. If it refuses your DM, for example because buttons, cards or media were sent to someone who does not follow you, that reply is spent. When in doubt, open with plain text or use the follow gate, then send buttons once the person answers. ## Repeat rules and pacing | Field | Effect | | --- | --- | | `repeatPolicy.mode: "once"` (default) | One DM per person per automation, ever. On reply-only platforms, one public reply per person | | `repeatPolicy.mode: "every_comment"` | Every new matching comment is eligible. One comment is still answered at most once | | `repeatPolicy.cooldownHours` | With `every_comment`, skip a repeat to the same person within this many hours. Sending it with `once` is a `400` | | `dedupeSameTextHours` | Skip the DM if this person already got the identical text from this account, from any automation, within this many hours | Platforms watch for accounts that send the same message to many people in a burst. Variations, deduplication and a small `dmDelaySeconds` all make an automation look less like spam and keep the account in good standing. ## How comments are caught Comments reach Pozy by webhook in real time. As a safety net, Pozy also reads the latest comments every 10 minutes (the bound post, or the 5 newest posts for an account-wide automation) and answers any the webhook missed. Each comment is answered at most once, whichever path sees it first. On Threads, LinkedIn and YouTube, comments are read by that poll only, so replies follow within about 10 minutes on posts from the last day, and less often on older posts. TikTok comments arrive by webhook for business accounts. ## Check what happened `GET /v1/comment-automations/{id}/logs` lists every comment that triggered the automation, with the send status and the commenter. Filter by `status`: | `status` | Meaning | | --- | --- | | `sent` | DM delivered | | `pending` | Waiting on a delay or a follow gate | | `gated` | A follow-gate message was sent and is waiting for the tap | | `skipped` | Suppressed by a repeat rule, dedupe or audience rule. `error` says which | | `failed` | The platform refused. `platformError` has the code | Each row also shows the public reply status, `likeSkipped` and `hideSkipped` reasons, and link `clickCount`. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400` `missing_required_field` | No `dmMessage` on Instagram or Facebook | Add it | | `400` naming a DM field | A DM field on a reply-only platform | Send `commentReply` only | | `400` with `cooldownHours` | `cooldownHours` with mode `once` | Use `every_comment` | | `400` `platform_not_supported` | An X account | Not available on X | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/comment-automations`](https://pozy.ai/docs/api/listCommentAutomations) | List comment-to-DM automations | | [`POST /v1/comment-automations`](https://pozy.ai/docs/api/createCommentAutomation) | Create comment-to-DM automation | | [`GET /v1/comment-automations/{automationId}`](https://pozy.ai/docs/api/getCommentAutomation) | Get automation details | | [`PATCH /v1/comment-automations/{automationId}`](https://pozy.ai/docs/api/updateCommentAutomation) | Update automation settings | | [`DELETE /v1/comment-automations/{automationId}`](https://pozy.ai/docs/api/deleteCommentAutomation) | Delete automation | | [`GET /v1/comment-automations/{automationId}/logs`](https://pozy.ai/docs/api/listCommentAutomationLogs) | List automation logs | --- # Workflows Build conversation flows that run on one messaging account when a trigger fires: send messages, wait for answers, branch, call your backend and hand off to a person. A workflow is a graph of steps that runs on one messaging account. When its trigger fires (a DM, a link click, a comment, a story mention, a call from your backend) Pozy starts a **run** for that contact and walks the graph: send a message, wait for the reply, branch on it, tag the contact, call your webhook, hand off to a person. Workflows run on WhatsApp, Instagram, Facebook, X, Bluesky and TikTok. Telegram is coming soon. For a simple keyword reply under a post, a [comment automation](https://pozy.ai/docs/automations/comment-to-dm) is less work. ## Your first workflow This Instagram workflow answers anyone who writes "pricing", asks which plan they want, and tags them by answer. It is created as a draft, then activated. ```bash cURL curl -X POST https://api.pozy.ai/v1/workflows \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a22", "platform": "instagram", "name": "Pricing questions", "nodes": [ { "id": "start", "type": "trigger", "config": { "triggerType": "inbound_message", "keywords": ["pricing", "price"], "matchType": "contains", "cooldownHours": 24 } }, { "id": "ask", "type": "send_message", "config": { "messageType": "text", "text": "Happy to help, {{contact.name}}. Which plan are you looking at?", "quickReplies": [ { "title": "Solo", "payload": "PLAN_SOLO" }, { "title": "Team", "payload": "PLAN_TEAM" } ] } }, { "id": "wait", "type": "wait_for_reply", "config": { "timeoutMinutes": 1440, "saveAs": "plan" } }, { "id": "route", "type": "condition", "config": { "rules": [ { "id": "team", "variable": "quickReply.payload", "operator": "equals", "value": "PLAN_TEAM" } ] } }, { "id": "tag_team", "type": "add_tag", "config": { "tag": "lead-team" } }, { "id": "handoff", "type": "handoff", "config": { "note": "Team plan lead, reply within the hour" } }, { "id": "solo", "type": "send_message", "config": { "messageType": "text", "text": "Solo is $12 a month. Start here: https://acme.example/solo" } }, { "id": "done", "type": "end" } ], "edges": [ { "id": "e1", "source": "start", "target": "ask" }, { "id": "e2", "source": "ask", "target": "wait" }, { "id": "e3", "source": "wait", "target": "route", "sourceHandle": "reply" }, { "id": "e4", "source": "wait", "target": "done", "sourceHandle": "timeout" }, { "id": "e5", "source": "route", "target": "tag_team", "sourceHandle": "team" }, { "id": "e6", "source": "route", "target": "solo", "sourceHandle": "default" }, { "id": "e7", "source": "tag_team", "target": "handoff" }, { "id": "e8", "source": "solo", "target": "done" } ] }' ``` ```ts Node const createdRes = await fetch("https://api.pozy.ai/v1/workflows", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", accountId: "6650c1e2a3b4c5d6e7f80a22", platform: "instagram", name: "Pricing questions", nodes: [ { id: "start", type: "trigger", config: { triggerType: "inbound_message", keywords: ["pricing", "price"], matchType: "contains", cooldownHours: 24 } }, { id: "ask", type: "send_message", config: { messageType: "text", text: "Happy to help, {{contact.name}}. Which plan are you looking at?", quickReplies: [{ title: "Solo", payload: "PLAN_SOLO" }, { title: "Team", payload: "PLAN_TEAM" }] } }, { id: "wait", type: "wait_for_reply", config: { timeoutMinutes: 1440, saveAs: "plan" } }, { id: "route", type: "condition", config: { rules: [{ id: "team", variable: "quickReply.payload", operator: "equals", value: "PLAN_TEAM" }] } }, { id: "tag_team", type: "add_tag", config: { tag: "lead-team" } }, { id: "handoff", type: "handoff", config: { note: "Team plan lead, reply within the hour" } }, { id: "solo", type: "send_message", config: { messageType: "text", text: "Solo is $12 a month. Start here: https://acme.example/solo" } }, { id: "done", type: "end" }, ], edges: [ { id: "e1", source: "start", target: "ask" }, { id: "e2", source: "ask", target: "wait" }, { id: "e3", source: "wait", target: "route", sourceHandle: "reply" }, { id: "e4", source: "wait", target: "done", sourceHandle: "timeout" }, { id: "e5", source: "route", target: "tag_team", sourceHandle: "team" }, { id: "e6", source: "route", target: "solo", sourceHandle: "default" }, { id: "e7", source: "tag_team", target: "handoff" }, { id: "e8", source: "solo", target: "done" }, ], }), }); const created = await createdRes.json(); if (!createdRes.ok) throw new Error(created.error); const res = await fetch(`https://api.pozy.ai/v1/workflows/${created.workflow.id}/activate`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} workflow = { "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a22", "platform": "instagram", "name": "Pricing questions", "nodes": [ {"id": "start", "type": "trigger", "config": {"triggerType": "inbound_message", "keywords": ["pricing", "price"], "matchType": "contains"}}, {"id": "ask", "type": "send_message", "config": {"messageType": "text", "text": "Which plan are you looking at?", "quickReplies": [{"title": "Solo", "payload": "PLAN_SOLO"}, {"title": "Team", "payload": "PLAN_TEAM"}]}}, {"id": "wait", "type": "wait_for_reply", "config": {"timeoutMinutes": 1440, "saveAs": "plan"}}, {"id": "done", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start", "target": "ask"}, {"id": "e2", "source": "ask", "target": "wait"}, {"id": "e3", "source": "wait", "target": "done", "sourceHandle": "reply"}, {"id": "e4", "source": "wait", "target": "done", "sourceHandle": "timeout"}, ], } created = requests.post("https://api.pozy.ai/v1/workflows", headers=H, json=workflow, timeout=30).json() wid = created["workflow"]["id"] requests.post(f"https://api.pozy.ai/v1/workflows/{wid}/activate", headers=H, timeout=30) ``` The create call returns the draft with its `id`. Only an `active` workflow starts runs, so a workflow that "does nothing" is usually still `draft` or `paused`. ## Lifecycle | Status | Meaning | Move with | | --- | --- | --- | | `draft` | Saved, not running | `POST /v1/workflows/{id}/activate` | | `active` | Starts runs when its trigger fires | `POST /v1/workflows/{id}/pause` | | `paused` | Starts no new runs. Runs already in progress finish | `POST /v1/workflows/{id}/activate` | Creating validates the structure: node ids must be unique, edges must point at existing nodes, and WhatsApp-only nodes cannot appear on other platforms. Activating also requires exactly one trigger node and a graph where every node can be reached. ## Triggers Every workflow has one `trigger` node. Its `config.triggerType` decides what starts a run. | `triggerType` | Starts when | Platforms | | --- | --- | --- | | `inbound_message` (default) | The account receives a DM. Filter with `keywords` and `matchType` (`any`, `contains`, `exact`, `regex`). `onlyFirstMessage: true` fires once per contact | All workflow platforms | | `referral` | Someone opens the chat from an ig.me or m.me link with a `ref`, or a click-to-message ad. Narrow with `referral: { ref, matchType }` (`exact`, `prefix`, `any`) | Instagram, Facebook, WhatsApp | | `comment` | Someone comments on a post. Filter with `comment: { keywords, matchType, platformPostId }` | Instagram, Facebook | | `story_mention` | Someone mentions the account in their story | Instagram | | `reaction` | Someone reacts to a message. Filter with `reaction: { emojis }` | Instagram, Facebook, WhatsApp, Telegram groups | | `whatsapp_event` | A status arrives for a message the account sent (`eventType`: `message_sent`, `message_delivered`, `message_read`, `message_failed`, `reaction`) | WhatsApp | | `lead_received` | Someone submits a Meta lead ad form. The run messages the phone number on the form | WhatsApp | | `api_call` | Your backend starts a run | All workflow platforms | **Cooldown.** Within `cooldownHours` (1 to 720, 24 by default) of a run, the same contact cannot start the workflow again. Every trigger respects it except `comment` and `api_call`. If a platform delivers one event twice, the workflow still starts only once. **Comment runs** have no conversation yet. Put a `send_message` right after the trigger: it is sent as the private reply to the comment, and the rest of the run happens in the chat that reply creates. That message can be text, with buttons or quick replies if you like, or cards. For commenters who do not follow you, Instagram delivers plain text only, so keep it simple. When an active comment automation also matches the comment, the automation replies and the workflow stays idle. **Lead runs** start only for leads submitted in the last hour, after activation, that ticked the consent checkbox named in `leadReceived.consentCheckboxKey` and whose phone number parses. Scope them with `formIds` or `pageAccountIds`. Every message the run sends before the person answers must be an approved WhatsApp template, because they have never written to you. ## Graph, edges and branches `nodes[]` and `edges[]` make the graph. Each node has a stable `id`, a `type` and a `config`; an optional `label` (up to 80 characters) and `position` are for the visual builder. Each edge links `source` to `target`. A node with several outcomes picks the edge whose `sourceHandle` matches: | Node | `sourceHandle` values | | --- | --- | | `condition` | The id of the first rule that matched, or `default` | | `wait_for_reply` | `reply` or `timeout` | | `a_b_split` | `a` or `b` | | `webhook`, `hubspot_upsert_contact`, `enroll_sequence` | `success` or `error` | | `ai` | `success`, `error`, or `tool:` when the model picks a tool | | `start_call` | `success`, `permission_required` or `failed` | An edge without `sourceHandle` is the node's only or default output. Every node type and its fields are on [Workflow nodes](https://pozy.ai/docs/automations/workflow-nodes). ## Variables Strings in a node's `config` may include `{{variable}}`. The variables below are set at the start of each run; any the trigger did not provide are empty strings: | Group | Variables | | --- | --- | | Message | `lastMessage`, `inboundText`, `triggerText`, `conversationId` | | Contact | `contact.name`, `contact.phone`, `contact.email`, `contact.handle`, `contact.tags`, `contact.fields`, `contact.lastInteractionAt`, `contact.lastInteractionHoursAgo`, `contact.isFollower` (Instagram) | | Referral | `referral.ref`, `referral.source`, `referral.type`, `referral.adId`, `referral.adTitle`, `referral.adName`, `referral.campaignName` | | Taps | `postback.payload`, `postback.title`, `quickReply.payload`, `quickReply.title` | | Story, comment, reaction | `story.id`, `story.url`, `comment.id`, `comment.text`, `comment.platformPostId`, `reaction.emoji`, `reaction.action` | | Lead | `lead.id`, `lead.formId`, `lead.formName`, `lead.adId`, `lead.campaignId` and every answer by its key, such as `lead.full_name` and `lead.email` | Values captured with `saveAs` and every `set_variable` assignment join the same set. `api_call` runs also get the `variables` you send. An array or object interpolated into text renders as JSON. ## Buttons that start or answer workflows On Instagram and Facebook, buttons and quick replies connect workflows to each other: - A postback button or quick reply **with no payload** gets `pozy:workflow:`. A tap answers this run's pending `wait_for_reply`, or restarts the workflow. - A button with **`workflowId`** starts that other workflow instead. - Anywhere else (persistent menus, ice breakers, the Get Started button, comment automation DMs), a payload of `pozy:workflow:` starts that workflow when tapped. It must be active on the same account and profile. A tap on a different active workflow starts it and exits the contact's other live runs. ## Start a run from your backend An `api_call` workflow runs when you ask it to. `POST /v1/workflows/{workflowId}/trigger` takes exactly one target plus optional `variables`: | Target | Runs in | | --- | --- | | `conversationId` | That conversation on the workflow's account | | `contactId` | The contact's conversation on the workflow's account | | `to` | WhatsApp only: a phone number in E.164. The conversation is found or created | ```bash curl -X POST https://api.pozy.ai/v1/workflows/6650c1e2a3b4c5d6e7f80d55/trigger \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contactId": "6650c1e2a3b4c5d6e7f80c44", "variables": { "orderId": "A-1042", "carrier": "UPS" } }' ``` It answers `201` with `{ "executionId": "..." }`. Inside the run, `{{orderId}}` and `{{carrier}}` are available. ## Test a workflow `POST /v1/workflows/{workflowId}/executions` starts a run without a real inbound message. Pass `conversationId` (or `to` on WhatsApp) and an optional `text` that becomes `lastMessage`. Use it with a conversation from your own test account. ## Follow runs | Call | Returns | | --- | --- | | `GET /v1/workflows/{id}/executions` | Runs with `status` (`running`, `waiting`, `completed`, `exited`, `failed`), the current node, what it is waiting for and the accumulated variables | | `GET /v1/workflows/{id}/executions/{executionId}/events` | The run's timeline: every node started, completed, failed or skipped, the edge taken and the duration | `exited` is a deliberate early end, before the last node: a `handoff`, or a button that sent the contact into another workflow. Rather than polling, listen for `workflow.run.started`, `workflow.run.completed` and `workflow.run.failed`; the failed event says which node broke and why. ## Edit, version and copy - `PATCH /v1/workflows/{id}` changes the name, description, account or graph. Every graph edit is validated, and moving the workflow to another account re-checks it against that platform. A run already in progress picks up the saved graph at its next step; a run waiting on a node you removed completes instead of failing. For big changes, pause first and activate after. - Each graph change records a version. List them with `GET .../versions`, read one with `GET .../versions/{version}` and roll back with `POST .../versions/{version}/restore`. Restoring needs the workflow paused or in draft, and snapshots the current graph first, so a restore can itself be undone. - `POST .../duplicate` copies the graph, name and account into a new draft with no run history. Use it to experiment without touching the live version. The `nodes` array is replaced as a whole on update, so always send the full list. ## Errors | Error | Cause | | --- | --- | | `400` on create or update | Invalid structure: duplicate node ids, edges to missing nodes, a WhatsApp-only node on another platform, buttons on a platform that does not support them | | `400` on activate | No trigger node, nodes that cannot be reached, or an incomplete graph. The `error` message names the problem | | `400` on restore | The workflow is active. Pause it first | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/workflows`](https://pozy.ai/docs/api/listWorkflows) | List workflows | | [`POST /v1/workflows`](https://pozy.ai/docs/api/createWorkflow) | Create workflow | | [`GET /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/getWorkflow) | Get workflow with graph | | [`PATCH /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/updateWorkflow) | Update workflow | | [`DELETE /v1/workflows/{workflowId}`](https://pozy.ai/docs/api/deleteWorkflow) | Delete workflow | | [`POST /v1/workflows/{workflowId}/activate`](https://pozy.ai/docs/api/activateWorkflow) | Activate workflow | | [`POST /v1/workflows/{workflowId}/pause`](https://pozy.ai/docs/api/pauseWorkflow) | Pause workflow | | [`GET /v1/workflows/{workflowId}/executions`](https://pozy.ai/docs/api/listWorkflowExecutions) | List workflow runs | | [`POST /v1/workflows/{workflowId}/executions`](https://pozy.ai/docs/api/triggerWorkflow) | Manually start a workflow run | | [`POST /v1/workflows/{workflowId}/trigger`](https://pozy.ai/docs/api/triggerApiCallWorkflow) | Start an API-triggered workflow | | [`GET /v1/workflows/{workflowId}/executions/{executionId}/events`](https://pozy.ai/docs/api/listWorkflowExecutionEvents) | Get an execution's timeline | | [`POST /v1/workflows/{workflowId}/duplicate`](https://pozy.ai/docs/api/duplicateWorkflow) | Duplicate a workflow | | [`GET /v1/workflows/{workflowId}/versions`](https://pozy.ai/docs/api/listWorkflowVersions) | List a workflow's version history | | [`GET /v1/workflows/{workflowId}/versions/{version}`](https://pozy.ai/docs/api/getWorkflowVersion) | Get a specific workflow version | | [`POST /v1/workflows/{workflowId}/versions/{version}/restore`](https://pozy.ai/docs/api/restoreWorkflowVersion) | Restore a workflow version | ## Webhook events | Event | When it fires | | --- | --- | | [`workflow.run.started`](https://pozy.ai/docs/webhooks/events#workflow-run-started) | Workflow run started. | | [`workflow.run.completed`](https://pozy.ai/docs/webhooks/events#workflow-run-completed) | Workflow run completed. | | [`workflow.run.failed`](https://pozy.ai/docs/webhooks/events#workflow-run-failed) | Workflow run failed. | --- # Workflow nodes Every workflow node type, its config fields, its outputs and the platforms it runs on, from send_message and conditions to webhooks, AI and calls. A [workflow](https://pozy.ai/docs/automations/workflows) is made of nodes. Each node is `{ "id", "type", "config" }`, plus an optional `label` and canvas `position`. This page lists all 17 types and the fields their `config` takes. Every string field accepts `{{variable}}` interpolation, resolved when the node runs. | Group | Nodes | | --- | --- | | Messaging | [`send_message`](#send-message) | | Control flow | [`trigger`](#trigger), [`condition`](#condition), [`delay`](#delay), [`wait_for_reply`](#wait-for-reply), [`a_b_split`](#a-b-split), [`end`](#end) | | Data | [`set_variable`](#set-variable), [`set_field`](#set-field), [`add_tag` and `remove_tag`](#add-tag-and-remove-tag), [`enroll_sequence`](#enroll-sequence) | | Integrations | [`webhook`](#webhook), [`hubspot_upsert_contact`](#hubspot-upsert-contact), [`ai`](#ai), [`handoff`](#handoff), [`start_call`](#start-call) | ## trigger Exactly one per workflow. It decides what starts a run. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `triggerType` | string | `inbound_message` (default), `referral`, `comment`, `story_mention`, `reaction`, `whatsapp_event`, `lead_received`, `api_call` | | `keywords` | string[] | For `inbound_message`. Empty matches every message | | `matchType` | string | `any`, `contains`, `exact` or `regex` | | `onlyFirstMessage` | boolean | Fire only on a contact's first message | | `referral` | object | `{ ref, matchType }` with `matchType` `exact`, `prefix` or `any` (default) | | `comment` | object | `{ keywords, matchType, platformPostId }` | | `reaction` | object | `{ emojis }` to react only to some emojis | | `eventType` | string | For `whatsapp_event`: `message_sent`, `message_delivered`, `message_read`, `message_failed`, `reaction` | | `leadReceived` | object | `{ formIds, pageAccountIds, consentCheckboxKey, defaultCountry, allowCrossProfile }`. Needs `consentCheckboxKey` and at least one of `formIds` or `pageAccountIds` | | `cooldownHours` | integer | 1 to 720. Default 24 on every trigger except `comment` and `api_call` | A `whatsapp_event` workflow never starts on the status of a message sent by a workflow, sequence or comment automation, so it cannot loop on itself. ## send_message Sends one message into the run's conversation. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `messageType` | string | `text`, `media`, `template`, `interactive` or `cards` | | `text` | string | The message text. Up to 640 characters when `buttons` are attached | | `media` | object | `{ mediaType, url, caption }` with `mediaType` `image`, `video`, `audio` or `document`. Media cannot carry buttons | | `template` | object | WhatsApp: `{ name, language, variableMapping }` for an approved template. Instagram and Facebook with `messageType: "cards"`: `{ type: "generic", elements: [{ title, subtitle, imageUrl, buttons }] }`, 1 to 10 cards | | `interactive` | object | WhatsApp only: list, reply buttons, call-to-action URL, flow and other interactive messages | | `buttons` | object[] | Instagram and Facebook, on `text`: 1 to 3 `{ type, title, url, payload, phone, workflowId }` with `type` `url`, `postback` or `phone` (Facebook only) | | `quickReplies` | object[] | Instagram and Facebook: 1 to 13 `{ title, payload, imageUrl, workflowId }`. Not with `buttons` | Button and quick reply titles are capped at 20 characters, card titles and subtitles at 80. A postback with no `payload` gets `pozy:workflow:`, so a tap answers this workflow's pending `wait_for_reply`. `workflowId` on a button starts that workflow instead. Workflows can only send within the platform's messaging window. `messageTag` is not available: `HUMAN_AGENT` is reserved for replies typed by a person. On WhatsApp, a run that may start outside the 24-hour window must open with a template. ## wait_for_reply Pauses the run until the contact answers. Outputs: `reply` or `timeout`. | Field | Type | Notes | | --- | --- | --- | | `timeoutMinutes` | integer | Up to 43,200 (30 days). Takes the `timeout` edge after that much silence | | `saveAs` | string | Variable name for the reply text, for example `email` makes `{{email}}` | A button tap also counts as a reply and sets `{{postback.payload}}` and `{{postback.title}}`; a quick reply tap sets `{{quickReply.payload}}` and `{{quickReply.title}}`. Each reply clears the previous tap. ## condition Branches on variables. Outputs: the `id` of the first rule that matched, otherwise `default`. | Field | Type | Notes | | --- | --- | --- | | `rules` | object[] | `{ id, variable, operator, value }`, checked in order | | Operators | Compare | | --- | --- | | `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `matches`, `exists`, `not_exists` | Text, ignoring case. With `matches`, `value` is a regular expression | | `greater_than`, `less_than`, `greater_or_equal`, `less_or_equal` | Numbers. If either side is not a number, the condition is false | | `before`, `after` | Dates in ISO 8601 | | `has_tag`, `not_has_tag` | Checks whether a list variable, for example `contact.tags`, includes `value` | | `is_true`, `is_false` | Booleans; leave `value` out | ```json { "id": "route", "type": "condition", "config": { "rules": [ { "id": "vip", "variable": "contact.tags", "operator": "has_tag", "value": "vip" }, { "id": "recent", "variable": "contact.lastInteractionHoursAgo", "operator": "less_than", "value": "48" } ] } } ``` ## delay Waits, then continues. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `delayMinutes` | integer | Up to 43,200 (30 days) | The run is suspended while it waits and resumes on a timer. Remember the messaging window: a long delay on Instagram or Facebook can push the next message outside it. ## a_b_split Sends contacts down one of two branches at random. Outputs: `a` or `b`. | Field | Type | Notes | | --- | --- | --- | | `percentage` | number | 0 to 100, default 50. The share of runs that take `a` | Tag each branch with `add_tag` so you can compare results later. ## end Ends the run as `completed`. No config. ## set_variable Sets run-scoped variables that exist only for this run. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `assignments` | object[] | `{ name, value }` pairs. `value` can interpolate other variables | ## set_field Writes a [custom field](https://pozy.ai/docs/inbox/custom-fields) on the contact, which persists after the run. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `field` | string | The field slug. Characters outside `A-Z`, `a-z`, `0-9` and `_` are stripped | | `value` | string | The value, usually a captured variable such as `{{email}}` | Fires `contact.field_changed` when the value changes. Does nothing on `api_call` runs that have no contact. ## add_tag and remove_tag Add or remove one tag on the contact. Outputs: one edge. | Field | Type | Notes | | --- | --- | --- | | `tag` | string | The tag | Fire `contact.tag_added` and `contact.tag_removed` when they change something. Do nothing on `api_call` runs that have no contact. ## enroll_sequence Puts the contact into a [sequence](https://pozy.ai/docs/automations/sequences). Outputs: `success` or `error`. | Field | Type | Notes | | --- | --- | --- | | `sequenceId` | string | The sequence to enroll in | | `saveAs` | string | Variable for the result | ## webhook Calls your server and saves the response. Outputs: `success` on a `2xx`, otherwise `error`. | Field | Type | Notes | | --- | --- | --- | | `url` | string | Your endpoint. Private, loopback and cloud metadata addresses are refused | | `method` | string | `GET`, `POST`, `PUT`, `PATCH` or `DELETE` | | `headers` | object | Extra headers, for example your own auth token | | `bodyTemplate` | string | Request body with `{{variables}}` | | `saveAs` | string | Variable for the response, saved as `{ status, ok, body }` | ```json { "id": "lookup", "type": "webhook", "config": { "url": "https://api.acme.example/orders/lookup", "method": "POST", "headers": { "Authorization": "Bearer acme_internal_token" }, "bodyTemplate": "{\"phone\": \"{{contact.phone}}\", \"text\": \"{{lastMessage}}\"}", "saveAs": "order" } } ``` Then use `{{order.body.status}}` in a later message or condition. Check the signature or token on your side, since anyone who learns the URL could call it. ## hubspot_upsert_contact Creates or updates the contact in the profile's connected [HubSpot](https://pozy.ai/docs/platforms/hubspot) portal. Outputs: `success` or `error` (HubSpot not connected, or HubSpot refused the write). | Field | Type | Notes | | --- | --- | --- | | `email`, `phone`, `firstName`, `lastName` | string | Usually variables captured earlier | | `properties` | object | HubSpot internal property names to values, up to 50 | | `saveAs` | string | Variable for `{ id, created }` or `{ error }`. Default `hubspotContact` | Pozy updates the HubSpot contact already linked to this person, else one matching the email, then the phone, else it creates one. An existing HubSpot contact only gets the fields this node sets; empty values are skipped. ## ai Asks a language model and saves the answer. Outputs: `success`, `error`, or `tool:` when the model chooses one of your tools. | Field | Type | Notes | | --- | --- | --- | | `provider` | string | `anthropic`, `openai`, `google`, `mistral`, `groq` or `openrouter`. With `model`, the node runs on your own key for that provider | | `model` | string | The provider's model name | | `preset` | string | `smart`, `tools` or `cheap` | | `systemPrompt` | string | Instructions for the model | | `userPromptTemplate` | string | The prompt, usually with `{{lastMessage}}` and contact variables | | `outputType` | string | `text` or `json` | | `saveAs` | string | Variable for the output | | `temperature`, `maxTokens` | number | Generation settings | | `tools` | object[] | `{ name, description, parameters }`. When the model picks one, the run follows the `tool:` edge | Use tools to let the model route the conversation: for example a `book_demo` tool whose edge leads to a booking message, and a `human` tool whose edge leads to a `handoff`. ## handoff Ends the run as `exited` and flags the conversation for a person. No output edge. | Field | Type | Notes | | --- | --- | --- | | `note` | string | Context for whoever picks it up | | `assignTo` | string | Who should handle it | To also pass a Meta thread to another app, call [thread control](https://pozy.ai/docs/inbox/messages#hand-a-conversation-to-another-app) when `workflow.run.completed` arrives with `execution.status: "exited"`. ## start_call Places a WhatsApp call to the contact. WhatsApp only. Outputs: `success`, `permission_required` or `failed`. | Field | Type | Notes | | --- | --- | --- | | `to` | string | The number to call, usually `{{contact.phone}}` | | `forwardTo` | string | Where to connect the call: `tel:+E164`, `sip:user@host`, or a `wss://` URL for an AI voice agent | | `requirePermissionFirst` | boolean | Ask for call permission before dialing | | `recordingEnabled` | boolean | Record the call | | `saveAs` | string | Variable for the result | WhatsApp requires the person's permission before a business calls them. Route `permission_required` to a message explaining why you want to call. See [WhatsApp calling](https://pozy.ai/docs/whatsapp/calling). ## Validation Saving a workflow checks that node ids are unique, that edges point at existing nodes, that WhatsApp-only nodes (`start_call`, `template` and `interactive` messages) are not used on other platforms, and that Instagram and Facebook-only fields (`buttons`, `quickReplies`, `cards`) are not used elsewhere. A problem answers `400` with a message that describes it. --- # Sequences Send a series of timed messages to contacts who opted in, with automatic exits when they reply or unsubscribe, on WhatsApp, Instagram, Facebook and more. A sequence is a drip campaign over DMs: a list of steps, each with a delay and a message, sent to every contact you enroll. Typical uses are onboarding after a sign-up, a follow-up series after a lead form, or a reminder before an appointment. Contacts leave automatically when they reply or unsubscribe, so a person who starts talking to you stops getting scripted messages. Sequences run on one account: WhatsApp, Instagram, Facebook, X, Bluesky or Slack. Telegram is coming soon. ## Create a sequence ```bash cURL curl -X POST https://api.pozy.ai/v1/sequences \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "name": "Trial onboarding", "steps": [ { "order": 1, "delayMinutes": 0, "template": { "name": "trial_welcome", "language": "en", "variableMapping": { "1": { "field": "name" } } } }, { "order": 2, "delayMinutes": 1440, "template": { "name": "trial_day_two_tips", "language": "en" } }, { "order": 3, "delayMinutes": 7200, "template": { "name": "trial_ending", "language": "en" } } ], "exitOnReply": true, "exitOnUnsubscribe": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/sequences", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", accountId: "6650c1e2a3b4c5d6e7f80a44", platform: "whatsapp", name: "Trial onboarding", steps: [ { order: 1, delayMinutes: 0, template: { name: "trial_welcome", language: "en", variableMapping: { "1": { field: "name" } } } }, { order: 2, delayMinutes: 1440, template: { name: "trial_day_two_tips", language: "en" } }, { order: 3, delayMinutes: 7200, template: { name: "trial_ending", language: "en" } }, ], exitOnReply: true, exitOnUnsubscribe: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const res2 = await fetch(`https://api.pozy.ai/v1/sequences/${data.sequence.id}/activate`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); if (!res2.ok) throw new Error((await res2.json()).error); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} seq = requests.post( "https://api.pozy.ai/v1/sequences", headers=H, json={ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "name": "Trial onboarding", "steps": [ {"order": 1, "delayMinutes": 0, "template": {"name": "trial_welcome", "language": "en"}}, {"order": 2, "delayMinutes": 1440, "template": {"name": "trial_day_two_tips", "language": "en"}}, ], }, timeout=30, ).json()["sequence"] requests.post(f"https://api.pozy.ai/v1/sequences/{seq['id']}/activate", headers=H, timeout=30) ``` A sequence starts as a `draft`. Activate it to start sending; it needs at least one step. ### Steps | Field | Notes | | --- | --- | | `order` | Position of the step, starting at 1 | | `delayMinutes` | How long to wait before sending this step. `0` on the first step sends it as soon as the contact is enrolled | | `message` | `{ text }` for a free-form message | | `template` | `{ name, language, variableMapping }` for an approved WhatsApp template | Check `nextStepAt` on each enrollment to see exactly when the next step goes out. ### Pick message or template per platform - **WhatsApp:** a step that may go out more than 24 hours after the contact last wrote must be an approved template. In practice, use templates for every step. `variableMapping` maps template placeholders to `name`, `phone`, `email`, `company` or a fixed `custom` value. See [Templates](https://pozy.ai/docs/whatsapp/templates). - **Instagram and Facebook:** automated messages can only be sent within 24 hours of the contact's last message, so a long sequence stops reaching people who went quiet. Keep sequences short there, or use them right after a conversation starts. - **Telegram, X, Bluesky, Slack:** `message.text` steps. ## Enroll contacts ```bash cURL curl -X POST https://api.pozy.ai/v1/sequences/6650c1e2a3b4c5d6e7f80f77/enroll \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contactIds": ["6650c1e2a3b4c5d6e7f80c44", "6650c1e2a3b4c5d6e7f80c45"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/sequences/6650c1e2a3b4c5d6e7f80f77/enroll", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ contactIds: ["6650c1e2a3b4c5d6e7f80c44", "6650c1e2a3b4c5d6e7f80c45"] }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.enrolled, data.failed, data.results); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/sequences/6650c1e2a3b4c5d6e7f80f77/enroll", headers=H, json={"contactIds": ["6650c1e2a3b4c5d6e7f80c44", "6650c1e2a3b4c5d6e7f80c45"]}, timeout=30, ) print(resp.json()) ``` Pozy picks each contact's channel on the sequence's account; pass `channelIds` to choose it yourself. Contacts already enrolled are skipped. The response lists a result per contact, so check `failed` and the `results` array. Other ways to enroll: - A workflow's [`enroll_sequence`](https://pozy.ai/docs/automations/workflow-nodes#enroll-sequence) node enrolls the contact in the middle of a conversation, for example after they share an email. - Your backend can enroll contacts when something happens in your product, such as a trial starting. Only enroll people who agreed to hear from you. On WhatsApp and SMS, that consent is a platform rule, not just good practice. ## How contacts leave | `exitReason` | Why | | --- | --- | | `completed` | The last step was sent | | `replied` | The contact replied, and `exitOnReply` is on (the default) | | `unsubscribed` | The contact opted out, and `exitOnUnsubscribe` is on (the default) | | `manual` | You removed them with `DELETE /v1/sequences/{id}/enroll/{contactId}` | | `failed` | A step kept failing to send | `sequence.enrolled` and `sequence.exited` webhooks report each move, with `exitReason` on exits. ## Monitor and change - `GET /v1/sequences/{id}` returns the steps and totals (`totalEnrolled`, `totalCompleted`, `totalExited`). - `GET /v1/sequences/{id}/enrollments` lists contacts with `status` (`active`, `completed`, `exited`, `paused`), `currentStepIndex`, `stepsSent`, `lastStepSentAt` and `nextStepAt`. - `POST /v1/sequences/{id}/pause` stops all sends until you activate it again. Enrolled contacts keep their place. - `PATCH /v1/sequences/{id}` changes the name, exits or steps. The step list is replaced as a whole and can only change while the sequence is `draft` or `paused`. - `DELETE /v1/sequences/{id}` deletes the sequence and stops every active enrollment. Messages a sequence sends arrive as `message.sent` with `sentVia: "sequence"` and the `sequenceId`, and count toward your outbound messages. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/sequences`](https://pozy.ai/docs/api/listSequences) | List sequences | | [`POST /v1/sequences`](https://pozy.ai/docs/api/createSequence) | Create sequence | | [`GET /v1/sequences/{sequenceId}`](https://pozy.ai/docs/api/getSequence) | Get sequence with steps | | [`PATCH /v1/sequences/{sequenceId}`](https://pozy.ai/docs/api/updateSequence) | Update sequence | | [`DELETE /v1/sequences/{sequenceId}`](https://pozy.ai/docs/api/deleteSequence) | Delete sequence | | [`POST /v1/sequences/{sequenceId}/activate`](https://pozy.ai/docs/api/activateSequence) | Activate sequence | | [`POST /v1/sequences/{sequenceId}/pause`](https://pozy.ai/docs/api/pauseSequence) | Pause sequence | | [`POST /v1/sequences/{sequenceId}/enroll`](https://pozy.ai/docs/api/enrollContacts) | Enroll contacts in a sequence | | [`DELETE /v1/sequences/{sequenceId}/enroll/{contactId}`](https://pozy.ai/docs/api/unenrollContact) | Unenroll contact | | [`GET /v1/sequences/{sequenceId}/enrollments`](https://pozy.ai/docs/api/listSequenceEnrollments) | List enrollments for a sequence | ## Webhook events | Event | When it fires | | --- | --- | | [`sequence.enrolled`](https://pozy.ai/docs/webhooks/events#sequence-enrolled) | Sequence enrolled. | | [`sequence.exited`](https://pozy.ai/docs/webhooks/events#sequence-exited) | Sequence exited. | --- # Broadcasts Send one message to many contacts at once, now or at a scheduled time, with per-recipient variables, segments and delivery tracking. A broadcast sends one message from one account to a list of recipients: an order update to everyone who bought this week, a launch announcement to a tag, an event reminder to a segment. You create it as a draft, add recipients, then send it now or schedule it. Pozy tracks every recipient as sent, delivered, read or failed. Broadcasts work on WhatsApp, SMS, iMessage, Instagram, Facebook, X, Bluesky and Slack. Telegram is coming soon. WhatsApp broadcasts send approved templates; the others send text. ## Create, fill and send ### Create the draft ```bash cURL curl -X POST https://api.pozy.ai/v1/broadcasts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "name": "Black Friday early access", "template": { "name": "early_access", "language": "en", "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "{{1}}" }, { "type": "text", "text": "{{2}}" }] }], "variableMapping": { "1": { "field": "name" }, "2": { "field": "custom", "customValue": "BF-EARLY-20" } } }, "segmentFilters": { "tags": ["vip"], "isSubscribed": true } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/broadcasts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1e2a3b4c5d6e7f80911", accountId: "6650c1e2a3b4c5d6e7f80a44", platform: "whatsapp", name: "Black Friday early access", template: { name: "early_access", language: "en", components: [{ type: "body", parameters: [{ type: "text", text: "{{1}}" }, { type: "text", text: "{{2}}" }] }], variableMapping: { "1": { field: "name" }, "2": { field: "custom", customValue: "BF-EARLY-20" } }, }, segmentFilters: { tags: ["vip"], isSubscribed: true }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const broadcastId = data.broadcast.id; ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} created = requests.post( "https://api.pozy.ai/v1/broadcasts", headers=H, json={ "profileId": "6650c1e2a3b4c5d6e7f80911", "accountId": "6650c1e2a3b4c5d6e7f80a44", "platform": "whatsapp", "name": "Black Friday early access", "template": {"name": "early_access", "language": "en", "variableMapping": {"1": {"field": "name"}}}, "segmentFilters": {"tags": ["vip"], "isSubscribed": True}, }, timeout=30, ).json() broadcast_id = created["broadcast"]["id"] ``` ### Add recipients `POST /v1/broadcasts/{broadcastId}/recipients` with one of: - `useSegment: true` to add every contact matching `segmentFilters` - `contactIds` with Pozy contact ids - `phones` with E.164 numbers (WhatsApp, SMS). Pozy creates contacts for numbers it has not seen ```bash curl -X POST https://api.pozy.ai/v1/broadcasts/6650c1e2a3b4c5d6e7f80a88/recipients \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "useSegment": true }' ``` The response counts `added` and `skipped`. Duplicates and contacts with no channel on this account are skipped. Call it several times to combine sources. ### Send or schedule - `POST /v1/broadcasts/{id}/send` starts delivery now. - `POST /v1/broadcasts/{id}/schedule` with `scheduledAt` (ISO 8601, in the future) sends it later. Call it again to move the date. - `POST /v1/broadcasts/{id}/cancel` stops a scheduled broadcast. ## Messages and templates | Platform | What to send | | --- | --- | | WhatsApp | `template` with an approved template's `name` and `language`, plus `components` and `variableMapping` when it has placeholders | | SMS | `message.text`, and optionally `message.attachments` for MMS media (JPEG, PNG, GIF, WEBP, MP4 or 3GPP, public URLs, about 1 MB in total) | | Everything else | `message.text` | `variableMapping` fills numbered placeholders (`{{1}}`, `{{2}}`) per recipient at send time: | `field` | Becomes | | --- | --- | | `name` | The contact's name, or "there" when it is empty | | `phone` | The recipient's number | | `email`, `company` | The contact's email or company | | `custom` | The fixed `customValue`, the same for everyone | > **Placeholder counts must match:** > > The number of `variableMapping` entries must equal the number of placeholders in the template body. A mismatch makes WhatsApp reject each send with a parameter-count error. Named placeholders are not supported; use numbered ones. ## Segments `segmentFilters` describes who `useSegment` adds: | Field | Matches | | --- | --- | | `tags` | Contacts with these tags | | `isSubscribed` | `true` to leave out people who opted out | | `customFields` | Exact custom field values by slug, type included (`5` does not match `"5"`). Every key must match | Set `isSubscribed: true` on every marketing broadcast. ## Platform rules that cap delivery - **WhatsApp messaging limits.** A number's tier caps how many unique people it can start conversations with per day. A newly connected number starts at 250 a day, and recipients above the cap do not deliver. Check the number's tier before a large send; see [WhatsApp phone numbers](https://pozy.ai/docs/whatsapp/phone-numbers). - **Instagram and Facebook.** A broadcast only reaches people who messaged the account in the last 24 hours. Message tags are not usable: `HUMAN_AGENT` is for replies typed by a person, and the others were retired. - **SMS in the US** needs a registered 10DLC campaign. See [10DLC](https://pozy.ai/docs/telephony/10dlc). - **Costs.** Each recipient counts as one outbound message. WhatsApp template fees are charged by WhatsApp on top. See [WhatsApp pricing](https://pozy.ai/docs/whatsapp/pricing) and [Pricing](https://pozy.ai/docs/billing). ## Track results `GET /v1/broadcasts/{id}` shows `status` (`draft`, `scheduled`, `sending`, `completed`, `failed`, `cancelled`) and the counters `recipientCount`, `sentCount`, `deliveredCount`, `readCount` and `failedCount`. `GET /v1/broadcasts/{id}/recipients` lists each recipient with `status` (`pending`, `sent`, `delivered`, `read`, `failed`) and timestamps. Failed rows carry `error`, `errorCode` and `errorExplanation`. Filter with `status=failed` to see what went wrong. Every message also arrives as `message.sent` with `sentVia: "broadcast"` and the `broadcastId`, followed by delivery and read events where the platform reports them. ## Errors | Error | Cause | | --- | --- | | `400` "Broadcast has no recipients" | Send or schedule before adding recipients | | `400` "Cannot send broadcast in ... status" | The broadcast is already sending or done. Check `status` | | Per-recipient `failed` | Usually a template mismatch, an opted-out number or a number that is not on WhatsApp. The call itself still succeeds | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/broadcasts`](https://pozy.ai/docs/api/listBroadcasts) | List broadcasts | | [`POST /v1/broadcasts`](https://pozy.ai/docs/api/createBroadcast) | Create broadcast draft | | [`GET /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/getBroadcast) | Get broadcast details | | [`PATCH /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/updateBroadcast) | Update broadcast | | [`DELETE /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/deleteBroadcast) | Delete broadcast | | [`POST /v1/broadcasts/{broadcastId}/send`](https://pozy.ai/docs/api/sendBroadcast) | Send broadcast now | | [`POST /v1/broadcasts/{broadcastId}/schedule`](https://pozy.ai/docs/api/scheduleBroadcast) | Schedule broadcast for later | | [`POST /v1/broadcasts/{broadcastId}/cancel`](https://pozy.ai/docs/api/cancelBroadcast) | Cancel broadcast | | [`GET /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/listBroadcastRecipients) | List broadcast recipients | | [`POST /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/addBroadcastRecipients) | Add recipients to a broadcast | --- # Conversation starters Set the greeting, Get Started button, ice breakers and persistent menu people see when they open a chat with your Facebook Page or Instagram account, plus the WhatsApp equivalent. The first screen of a chat decides whether people write to you. Each messaging platform has its own version of that screen: a greeting, suggested questions, a menu. Pozy manages them per account, and any of them can start a [workflow](https://pozy.ai/docs/automations/workflows) when tapped. | Surface | What people see | Platforms | Endpoint | | --- | --- | --- | --- | | Greeting text | A short welcome before the first message | Facebook | `/v1/accounts/{accountId}/messenger-greeting` | | Get Started button | A button that opens the conversation | Facebook | `/v1/accounts/{accountId}/messenger-get-started` | | Ice breakers | Up to 4 suggested questions | Facebook, Instagram | `/v1/accounts/{accountId}/messenger-ice-breakers`, `/v1/accounts/{accountId}/instagram-ice-breakers` | | Persistent menu | A menu always available in the chat | Facebook, Instagram | `/v1/accounts/{accountId}/messenger-menu` | | Bot commands | The `/` command menu | Telegram (coming soon) | `/v1/accounts/{accountId}/telegram-commands` | | Prompts and commands | Suggested prompts, slash commands, welcome message | WhatsApp | `/v1/whatsapp/conversational-automation` | Each endpoint supports `GET` to read, `PUT` to replace (WhatsApp uses `POST`) and `DELETE` to clear. A `PUT` replaces everything already set, including other locales. ## How taps reach you A tap on a Get Started button, an ice breaker or a postback menu item sends a postback. It arrives as `message.received` with the tapped item's payload in `metadata.postbackPayload`. Set the payload to `pozy:workflow:` and the tap starts that workflow directly. The workflow must be active on the same account and profile. On WhatsApp, a tapped prompt arrives as an ordinary message with its text, so answer it with an `inbound_message` workflow keyed on that text. ## Facebook welcome screen ### Greeting ```bash curl -X PUT https://api.pozy.ai/v1/accounts/6650c1e2a3b4c5d6e7f80a55/messenger-greeting \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "greeting": [ { "locale": "default", "text": "Hi {{user_first_name}}, ask us anything about orders and returns." }, { "locale": "es_ES", "text": "Hola {{user_first_name}}, pregúntanos lo que quieras sobre pedidos." } ] }' ``` One entry must use locale `default`. Text is up to 160 characters. Meta fills `{{user_first_name}}`, `{{user_last_name}}` and `{{user_full_name}}` for each person. ### Get Started button `PUT .../messenger-get-started` with a single `payload` (up to 1,000 characters). Meta requires a Get Started button before it shows a persistent menu on a Page, so set this first. To remove it, delete the persistent menu first: Meta refuses while a menu is set. ### Ice breakers ```bash curl -X PUT https://api.pozy.ai/v1/accounts/6650c1e2a3b4c5d6e7f80a55/messenger-ice-breakers \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ice_breakers": [ { "locale": "default", "call_to_actions": [ { "question": "Where is my order?", "payload": "pozy:workflow:6650c1e2a3b4c5d6e7f80d55" }, { "question": "How do returns work?", "payload": "RETURNS_FAQ" } ] } ] }' ``` Up to 4 questions per locale, each up to 80 characters, with one locale `default`. ## Instagram ice breakers Instagram takes a flat list instead of locales: ```json { "ice_breakers": [ { "question": "Do you ship to Canada?", "payload": "SHIPPING_CA" }, { "question": "Talk to a person", "payload": "pozy:workflow:6650c1e2a3b4c5d6e7f80d56" } ] } ``` Up to 4 entries, each question up to 80 characters. ## Persistent menu The persistent menu uses Meta's menu format: up to 3 top-level items, up to 5 nested items. ```json { "persistent_menu": [ { "locale": "default", "composer_input_disabled": false, "call_to_actions": [ { "type": "postback", "title": "Track my order", "payload": "pozy:workflow:6650c1e2a3b4c5d6e7f80d55" }, { "type": "web_url", "title": "Shop", "url": "https://acme.example/shop" }, { "type": "postback", "title": "Talk to a person", "payload": "HUMAN" } ] } ] } ``` The same endpoint serves Facebook Pages and Instagram accounts. Instagram accounts connected through Facebook Login are set through their linked Page. ## Telegram bot commands Telegram is coming soon and cannot be connected yet. This is how its bot commands will work. ```json { "commands": [ { "command": "start", "description": "Start over" }, { "command": "orders", "description": "See your recent orders" }, { "command": "help", "description": "Talk to support" } ] } ``` Up to 100 commands. Names are lowercase letters, digits and underscores, up to 32 characters, without the leading slash. Descriptions are up to 256 characters. A command arrives as a normal message with its text, so match it with an `inbound_message` workflow. ## WhatsApp prompts and commands `POST /v1/whatsapp/conversational-automation` sets, for one WhatsApp `accountId`: | Field | Notes | | --- | --- | | `prompts` | Up to 3 suggested prompts, 80 characters each | | `commands` | Up to 30 slash commands, each `{ command_name, command_description }` | | `enable_welcome_message` | Turn the welcome message on or off | Only the fields you send change. `DELETE` clears every prompt and command and turns the welcome message off. See [WhatsApp](https://pozy.ai/docs/whatsapp). ## Facebook Page details The same group of endpoints reads and updates a Page's public info: `GET` and `PATCH /v1/accounts/{accountId}/facebook-page-info` cover `about` (up to 100 characters), `description`, `website`, `phone`, `emails` and opening `hours`. Only the fields you send change; send an empty string to clear one. Editing needs a Page role that can edit the Page. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/facebook-page-info`](https://pozy.ai/docs/api/getFacebookPageInfo) | Get a Facebook Page's public info | | [`PATCH /v1/accounts/{accountId}/facebook-page-info`](https://pozy.ai/docs/api/updateFacebookPageInfo) | Update a Facebook Page's public info | | [`GET /v1/accounts/{accountId}/messenger-menu`](https://pozy.ai/docs/api/getMessengerMenu) | Get persistent menu | | [`PUT /v1/accounts/{accountId}/messenger-menu`](https://pozy.ai/docs/api/setMessengerMenu) | Set persistent menu | | [`DELETE /v1/accounts/{accountId}/messenger-menu`](https://pozy.ai/docs/api/deleteMessengerMenu) | Delete persistent menu | | [`GET /v1/accounts/{accountId}/messenger-get-started`](https://pozy.ai/docs/api/getMessengerGetStarted) | Get FB Get Started button | | [`PUT /v1/accounts/{accountId}/messenger-get-started`](https://pozy.ai/docs/api/setMessengerGetStarted) | Set FB Get Started button | | [`DELETE /v1/accounts/{accountId}/messenger-get-started`](https://pozy.ai/docs/api/deleteMessengerGetStarted) | Delete FB Get Started button | | [`GET /v1/accounts/{accountId}/messenger-greeting`](https://pozy.ai/docs/api/getMessengerGreeting) | Get FB greeting text | | [`PUT /v1/accounts/{accountId}/messenger-greeting`](https://pozy.ai/docs/api/setMessengerGreeting) | Set FB greeting text | | [`DELETE /v1/accounts/{accountId}/messenger-greeting`](https://pozy.ai/docs/api/deleteMessengerGreeting) | Delete FB greeting text | | [`GET /v1/accounts/{accountId}/messenger-ice-breakers`](https://pozy.ai/docs/api/getMessengerIceBreakers) | Get FB ice breakers | | [`PUT /v1/accounts/{accountId}/messenger-ice-breakers`](https://pozy.ai/docs/api/setMessengerIceBreakers) | Set FB ice breakers | | [`DELETE /v1/accounts/{accountId}/messenger-ice-breakers`](https://pozy.ai/docs/api/deleteMessengerIceBreakers) | Delete FB ice breakers | | [`GET /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/getInstagramIceBreakers) | Get IG ice breakers | | [`PUT /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/setInstagramIceBreakers) | Set IG ice breakers | | [`DELETE /v1/accounts/{accountId}/instagram-ice-breakers`](https://pozy.ai/docs/api/deleteInstagramIceBreakers) | Delete IG ice breakers | | [`GET /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/getTelegramCommands) | Get TG bot commands | | [`PUT /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/setTelegramCommands) | Set TG bot commands | | [`DELETE /v1/accounts/{accountId}/telegram-commands`](https://pozy.ai/docs/api/deleteTelegramCommands) | Delete TG bot commands | | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/getWhatsAppConversationalAutomation) | Get ice breakers and commands | | [`POST /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/setWhatsAppConversationalAutomation) | Set ice breakers and commands | | [`DELETE /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/deleteWhatsAppConversationalAutomation) | Clear ice breakers and commands | --- # Ads Create, boost and measure campaigns on nine ad networks with one set of endpoints, from Meta and Google to LinkedIn, TikTok, ChatGPT and Whop. Pozy runs paid campaigns on nine ad networks through one set of `/v1/ads` endpoints. You connect an ad account once, then create campaigns, boost posts, build audiences, send conversions and read spend with the same request shapes everywhere. Each network keeps its own rules, and the pages in this section say where they differ. ## Supported networks | Network | Account platform | `platform` on ad endpoints | Connect with | | --- | --- | --- | --- | | [Meta Ads](https://pozy.ai/docs/ads/meta-ads) (Facebook, Instagram, Messenger, Threads, WhatsApp placements) | `metaads` | `facebook` or `instagram` | OAuth, classic or business login | | [Google Ads](https://pozy.ai/docs/ads/google-ads) (Search, Display, Performance Max, Demand Gen) | `googleads` | `google` | OAuth | | [TikTok Ads](https://pozy.ai/docs/ads/tiktok-ads) | `tiktokads` | `tiktok` | OAuth | | [LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads) | `linkedinads` | `linkedin` | OAuth, reuses the LinkedIn token when one is connected | | [Pinterest Ads](https://pozy.ai/docs/ads/pinterest-ads) | `pinterestads` | `pinterest` | OAuth, reuses the Pinterest token | | [X Ads](https://pozy.ai/docs/ads/x-ads) | `xads` | `twitter` | OAuth, created from a connected X account | | [Reddit Ads](https://pozy.ai/docs/ads/reddit-ads) | `redditads` | `reddit` | OAuth | | [ChatGPT Ads](https://pozy.ai/docs/ads/chatgpt-ads) | `openaiads` | `openai` | API key from ChatGPT Ads Manager | An ad connection is its own connected account with its own `accountId`. It sits in a [profile](https://pozy.ai/docs/build/profiles) next to the posting accounts, and it counts once toward [pricing](https://pozy.ai/docs/billing) however many ad accounts it can reach. > **Reddit:** > > Organic Reddit publishing is paused, but Reddit Ads is a separate connection and works normally. ## How the pieces fit Every network nests ads in three levels. Pozy uses the same three names for all of them, and maps each one to the network's own object. | Pozy level | Id field on an ad | Meta | Google | TikTok | LinkedIn | X | | --- | --- | --- | --- | --- | --- | --- | | Campaign | `platformCampaignId` | Campaign | Campaign | Campaign | Campaign group | Campaign | | Ad set | `platformAdSetId` (`adSetId`) | Ad set | Ad group | Ad group | Campaign | Line item | | Ad | `platformAdId`, plus Pozy's `_id` | Ad | Ad (or asset group on Performance Max) | Ad | Creative | Promoted post | A few more objects show up around them: - **Ad account** (`adAccountId`): the account the network bills, such as `act_1234567890` on Meta or a customer id on Google. One connection can reach many. - **Creative**: the headline, body, media, call to action and link. On Meta you can build one on its own and reuse it with `existingCreativeId`. - **Audience**: a list of people you upload or a segment the network builds for you. See [Audiences](https://pozy.ai/docs/ads/audiences). `POST /v1/ads/create` builds the whole tree in one call. `POST /v1/ads/boost` wraps an organic post in the same tree. `GET /v1/ads/tree` reads it back with metrics rolled up per level. ## Connect an ad account ### Start the connection Call `GET /v1/connect/{platform}/ads` with the `profileId`. `platform` is one of `facebook`, `instagram`, `linkedin`, `tiktok`, `twitter`, `pinterest`, `googleads` or `redditads`. ChatGPT Ads connects with an API key instead, through `POST /v1/connect/openai-ads/credentials`. ```bash cURL curl "https://api.pozy.ai/v1/connect/googleads/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&redirect_url=https://app.example.com/ads/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/googleads/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&redirect_url=https:%2F%2Fapp.example.com%2Fads%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // data.authUrl the first time, data.alreadyConnected + data.accountId afterwards ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/googleads/ads", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66b2e19d8c3f5a7e9d0b1c2a", "redirect_url": "https://app.example.com/ads/connected"}, ) print(resp.json()) ``` ### Send the user to the consent screen The first time, the response carries an `authUrl`. Open it in a browser. When the user finishes, they land on your `redirect_url`. LinkedIn and Pinterest skip this step when the profile already has a posting account with the ads permissions. ### Read the connection Call the same endpoint again. It now answers `alreadyConnected: true` with the `accountId` of the ad connection. Branch on that field rather than on the URL. ### Pick an ad account Call `GET /v1/ads/accounts?accountId=...` to list the ad accounts the connection reaches. Each `accounts[].id` is the `adAccountId` every other ads call takes. `adAccountId` or `adAccountIds` on the connect call limit the sync to the ad accounts you name. The full flow, with hosted and headless options, is in [Connecting accounts](https://pozy.ai/docs/connect). ## What each network supports | Capability | Meta | Google | TikTok | LinkedIn | Pinterest | X | Reddit | ChatGPT | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | [Create a campaign](https://pozy.ai/docs/ads/meta-ads) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [Boost an existing post](https://pozy.ai/docs/ads/boost-posts) | Yes | – | Yes (Spark Ads) | Yes | Yes | Yes | Yes | – | | Pause, resume, change budget | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | Edit targeting after launch | Yes | Campaign-level | Yes | Yes | – | – | Yes | – | | Swap the creative on a live ad | Yes | Search text | Yes | New media | – | – | – | – | | Duplicate a campaign | Yes | – | Yes | Yes | – | – | – | – | | [Targeting search](https://pozy.ai/docs/ads/targeting) | Yes | Yes | Yes | Yes | Interests | Yes | Yes | – (countries only) | | Reach estimate or forecast | Yes | – | – | Yes | Yes | Yes | – | – | | [Audiences](https://pozy.ai/docs/ads/audiences) | Yes | Customer Match | Yes | Yes | Yes | Yes | Target only | – | | [Conversions API](https://pozy.ai/docs/ads/conversions) | Yes | Yes | Yes | Yes | Yes | – | Yes | Yes | | [Pixels and tags](https://pozy.ai/docs/ads/pixels) | Yes | Google tag | Yes | Insight Tag | Yes | Yes | – | Yes | | [Lead forms](https://pozy.ai/docs/ads/lead-forms) | Yes | – | – | Yes | – | – | – | – | | [Product catalogs](https://pozy.ai/docs/ads/catalogs) | Yes | – | – | – | – | – | – | – | | [Automated rules](https://pozy.ai/docs/ads/rules) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | – | | [Reporting](https://pozy.ai/docs/ads/reporting) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | Demographic breakdowns | Per ad and campaign | – | Per ad | Firmographics | – | – | – | – | A field a network cannot honor is rejected at create time with a `400` that names it. Pozy never drops a field quietly. ## Money and units - Budgets and bids are whole units of the ad account's currency: `75` on a USD account is $75.00. Never cents, never micros. - Each campaign node reports its `currency`. Pozy never converts between currencies, so do not add spend across ad accounts that bill differently. - Raw reports from a network (Google GAQL rows, Keyword Planner, Meta live insights) keep that network's units. Google's micros, for example, divide by 1,000,000. ## Reviews and statuses Every network reviews ads before they deliver. A successful create means the ad exists, not that it runs. Each ad carries three separate fields: | Field | Answers | Values | | --- | --- | --- | | `status` | Is it delivering? | `active`, `paused`, `pending_review`, `rejected`, `completed`, `cancelled`, `error` | | `configuredStatus` | What did you set on this ad? | `ACTIVE`, `PAUSED` | | `reviewStatus` | What did the network's review decide? | `in_review`, `approved`, `rejected`, `with_issues` | `status` inherits from parents: an active ad under a paused campaign reads `paused`. Deleted ads stay in the tree as `cancelled`, so their past spend keeps counting. ## Retries Ad creates and boosts are not idempotent on the networks' side. A blind retry after a timeout can launch a second campaign that spends. Send an `Idempotency-Key` header on every create, boost and duplicate, and reuse it on retries. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency). ## Pricing Managed ads bill per active ad per month. The first 500 active ads in a workspace are free, then each costs $0.01 per month. Only ads that are running or in review count: paused, ended, rejected and deleted ads do not, and the count is metered daily, so pausing a campaign takes its ads off the meter the same day. There is no percentage of ad spend. The spend itself is billed by the network to the ad account's own payment method. | Active ads | Monthly cost | | --- | --- | | 400 | Free | | 1,000 | $5 | | 5,000 | $45 | | 50,000 | $495 | See [Pricing](https://pozy.ai/docs/billing) for every other meter and how they roll up on one invoice. ## Webhook events Subscribe to these to react to reviews, delivery problems, budgets and new leads without polling. Payloads are in the [events reference](https://pozy.ai/docs/webhooks/events). | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`lead.received`](https://pozy.ai/docs/webhooks/events#lead-received) | Lead received. | | [`ad.status_changed`](https://pozy.ai/docs/webhooks/events#ad-status-changed) | Ad status changed. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | | [`ad.delivery_issue`](https://pozy.ai/docs/webhooks/events#ad-delivery-issue) | Ad delivery issue. | | [`campaign.budget.depleted`](https://pozy.ai/docs/webhooks/events#campaign-budget-depleted) | Campaign budget depleted. | | [`ad_account.status_changed`](https://pozy.ai/docs/webhooks/events#ad-account-status-changed) | Ad account status changed. | | [`ad.video.processed`](https://pozy.ai/docs/webhooks/events#ad-video-processed) | Ad video processed. | | [`ad_rule.triggered`](https://pozy.ai/docs/webhooks/events#ad-rule-triggered) | Ad rule triggered. | ## Endpoints The ads surface is large. These are the calls most integrations start with; each network page lists its own. | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads`](https://pozy.ai/docs/api/listAds) | List ads | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/timeline`](https://pozy.ai/docs/api/getAdsTimeline) | Get daily account metrics | | [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options | | [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience | The full list, grouped by tag, is in the [API reference](https://pozy.ai/docs/api#ad-campaigns). --- # Meta Ads Run Facebook and Instagram campaigns from the API, with conversation ads, creative testing, reach and frequency buys, previews and live insights. Meta Ads covers Facebook, Instagram, Messenger, Threads, Audience Network and WhatsApp Status placements. It is the most complete network in Pozy: every operation in the [ads overview](https://pozy.ai/docs/ads#what-each-network-supports) works here, plus several that only Meta offers. ## Before you start On Meta's side you need: - A Business portfolio with at least one ad account (`act_`). You can also [create one through the API](#create-an-ad-account). - A Facebook Page. Ads run as the Page. Instagram placements also need an Instagram professional account linked to that Page. - A pixel or dataset if the campaign optimizes for conversions. See [Pixels and tags](https://pozy.ai/docs/ads/pixels). > **Instagram Login accounts cannot run ads:** > > An Instagram account connected with Instagram Login never carries ads permissions. Ads calls made with its `accountId` return `422` with code `linked_account_required`. Connect a `facebook` or `metaads` account in the same profile and use that one. ## Connect Call `GET /v1/connect/facebook/ads` (or `/v1/connect/instagram/ads`) with the `profileId`. Meta offers two login modes, picked with `loginMode`: | `loginMode` | What it creates | Use it when | | --- | --- | --- | | `classic` (default) | A `metaads` account linked to the profile's Facebook account. If that account already has the ads permissions, no consent screen appears. | You already publish to the Page through Pozy. | | `business` | A standalone `metaads` account with a system-user token. No Facebook posting account is created. | You only run ads, or you want a token that does not depend on one person staying an admin. | Either way, ads need a Page as the actor. With business login, pass `pageId` on the connect call or change the default Page later with `PUT /v1/accounts/{accountId}/facebook-page`. A `metaads` account is for ads only. Page and Instagram insights need the organic [Facebook](https://pozy.ai/docs/platforms/facebook) or [Instagram](https://pozy.ai/docs/platforms/instagram) connection, which is a separate connected account. ### Create an ad account `POST /v1/ads/accounts` creates a Meta ad account inside the customer's own Business portfolio. Find the portfolio with `GET /v1/ads/businesses`, then send `accountId` (an active `metaads` connection), `businessId`, `name`, `currency` and `timezoneId`. `timezoneId` is Meta's numeric timezone id, not an IANA name. The connection needs Business admin access. > **Warning:** > > A new ad account has no payment method. The customer adds one in Meta Ads Manager before anything delivers. The create is not idempotent: after a timeout, check the portfolio in Ads Manager before you try again. ## Create a campaign `POST /v1/ads/create` builds a campaign, an ad set and an ad in one call. Every field sits at the top level, and the network is inferred from `accountId`. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: spring-sale-us-feed" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Spring sale - US", "goal": "traffic", "budgetAmount": 40, "budgetType": "daily", "status": "PAUSED", "headline": "30% off everything this week", "body": "Free shipping on orders over $50.", "imageUrl": "https://cdn.example.com/spring-1080.jpg", "linkUrl": "https://shop.example.com/spring", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 25, "ageMax": 54 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "spring-sale-us-feed", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "act_1234567890", name: "Spring sale - US", goal: "traffic", budgetAmount: 40, budgetType: "daily", status: "PAUSED", headline: "30% off everything this week", body: "Free shipping on orders over $50.", imageUrl: "https://cdn.example.com/spring-1080.jpg", linkUrl: "https://shop.example.com/spring", callToAction: "SHOP_NOW", countries: ["US"], ageMin: 25, ageMax: 54, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.platformCampaignId, data.ad.reviewStatus); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "spring-sale-us-feed", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Spring sale - US", "goal": "traffic", "budgetAmount": 40, "budgetType": "daily", "status": "PAUSED", "headline": "30% off everything this week", "body": "Free shipping on orders over $50.", "imageUrl": "https://cdn.example.com/spring-1080.jpg", "linkUrl": "https://shop.example.com/spring", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 25, "ageMax": 54, }, timeout=60, ) resp.raise_for_status() print(resp.json()["ad"]["_id"]) ``` ```json { "ad": { "_id": "66d4a1b2c3e4f5a6b7c8d9e2", "name": "Spring sale - US", "platform": "facebook", "status": "paused", "configuredStatus": "PAUSED", "reviewStatus": "in_review", "adType": "standalone", "goal": "traffic", "budget": { "amount": 40, "type": "daily" }, "platformAdAccountId": "act_1234567890", "platformCampaignId": "120250000000000000", "platformAdSetId": "120250000000000001", "platformAdId": "120260000000000000", "creative": { "creativeId": "120250000000000005", "pageId": "811889972008357" } }, "message": "Ad created" } ``` Keep three ids from the response: `_id` for per-ad calls, `platformCampaignId` for campaign calls and `platformAdSetId` for ad set calls. `creative.creativeId` can be reused later as `existingCreativeId`. Without `status: "PAUSED"` the ad is created active and goes straight into Meta's review. To provision a campaign container first and add ad sets to it later, use `POST /v1/ads/campaigns`, then pass the campaign id as `existingCampaignId` on each create. `POST /v1/ads/campaigns` creates the campaign paused unless you send `status: "ACTIVE"`. ### Check a request without spending Add `validateOnly: true` to run the whole tree through Meta's own validator. Nothing is created. You get `200` with one result per node (`validated` or `skipped`) and Meta's error verbatim when something is wrong. Meta can only validate an ad set against a real campaign, so on a fresh tree the ad set reports `skipped` unless you pass `existingCampaignId`. Dry runs work on the single-ad shape only, not with `creatives[]`, `adSetId` or reserved buying. ## Goals `goal` picks the Meta objective and a sensible optimization goal for it. | `goal` | Meta objective | Needs | | --- | --- | --- | | `traffic` | Traffic | `linkUrl` | | `engagement` | Engagement | | | `awareness` | Awareness | | | `video_views` | Engagement, ThruPlay | A video creative | | `lead_generation` | Leads, instant form | `leadGenFormId` ([Lead forms](https://pozy.ai/docs/ads/lead-forms)) | | `lead_conversion` | Leads, website | `promotedObject` with a pixel | | `conversions` | Sales | `promotedObject.pixelId` and `customEventType` | | `app_promotion` | App promotion | `promotedObject.applicationId` and `objectStoreUrl` | | `catalog_sales` | Sales, catalog | `promotedObject.productCatalogId` / `productSetId` ([Catalogs](https://pozy.ai/docs/ads/catalogs)) | | `page_likes`, `page_visits` | Engagement | A Page | `optimizationGoal`, `billingEvent` and `promotedObject` override the defaults when you need Meta's exact vocabulary (for example `LINK_CLICKS` or `OFFSITE_CONVERSIONS`). ## Budgets and bidding | Field | Values | Notes | | --- | --- | --- | | `budgetAmount` | number | Whole units of the ad account currency. | | `budgetType` | `daily`, `lifetime` | A lifetime budget needs `endDate`. | | `budgetLevel` | `adset` (default), `campaign` | `campaign` is Meta's Advantage campaign budget (CBO). Read `budgetLevel` on the tree before you change a budget, because the update goes to whichever level owns it. | | `startDate`, `endDate` | ISO 8601 | A value without an offset is read in the ad account's timezone. | | `buyingType` | `AUCTION` (default), `RESERVED` | `RESERVED` is a [reach and frequency](#reach-and-frequency) buy. | Bid strategies go inside `platformSpecificData` on create: | `bidStrategy` | Companion field | Behavior | | --- | --- | --- | | `LOWEST_COST_WITHOUT_CAP` (default) | | Meta spends the budget for the most results. | | `LOWEST_COST_WITH_BID_CAP` | `bidAmount` | Hard ceiling per auction. | | `COST_CAP` | `bidAmount` | Target average cost per result. | | `LOWEST_COST_WITH_MIN_ROAS` | `roasAverageFloor` | Value-optimized sales campaigns only. `2.0` means 2x ROAS. | `platformSpecificData` also takes `dailyMinSpendTarget` and `lifetimeMinSpendTarget` (reserve part of a campaign budget for one ad set), `customerLifecycle` (new customers only, or all) and `adSchedule` (dayparting rows of `dayOfWeek`, `startHour`, `endHour`, which need a lifetime budget). **Budget schedules** raise a budget for a sale or a busy weekend: `POST /v1/ads/high-demand-periods` with exactly one of `campaignId` or `adSetId`, and `GET /v1/ads/high-demand-periods` to read them. **Value rule sets** raise or lower the bid for segments worth more or less to you, without splitting them into separate ad sets. Create one with `POST /v1/ads/value-rule-sets`, then attach it with `valueRuleSetId` on the create or on `PUT /v1/ads/ad-sets/{adSetId}`. Meta allows 6 sets per ad account, 10 rules per set and 4 criteria per rule. An update replaces the whole set, so read it first and send every rule you want to keep. ## Creatives and formats Exactly one media source per creative. Pick the shape: | Shape | Fields | Notes | | --- | --- | --- | | Single image | `imageUrl` | JPEG or PNG, up to 30 MB. | | Single video | `video: { url, thumbnailUrl? }` or `video: { id }` | MP4 or MOV up to 4 GB. The create waits for Meta to finish processing, up to 10 minutes, so set your HTTP timeout above that. | | Carousel | `carouselCards` (2 to 10) | Each card has its own image and optional headline, description, link and button. `optimizeCardOrder` lets Meta reorder. | | N ads in one ad set | `creatives[]` | See [creative testing](#creative-testing). | | Asset pool | `dynamicCreative` | Up to several images, videos, bodies and titles that Meta recombines. | | Per-placement assets | `placementAssets` | A 9:16 asset on Stories and Reels and a 1:1 on Feed, in one ad. All images or all videos. 1 to 10 rules. | | Several languages | `translations` + `defaultLocale` | Up to 10 locales on one ad, so engagement stays on one post. Every locale needs its own headline, body and description. | | Reuse a creative | `existingCreativeId` | From an earlier create or `POST /v1/ads/creatives`. | `callToAction` takes the values listed in the [API reference](https://pozy.ai/docs/api/createStandaloneAd), such as `SHOP_NOW`, `LEARN_MORE`, `SIGN_UP` and `CONTACT_US`. Media URLs must be public: Meta downloads them itself, and a hotlink-protected host fails. ### Advantage+ options - `creativeFeatures` opts a creative in or out of Meta's individual Advantage+ creative enhancements, keyed by Meta's feature names. Anything you leave out is opted out. - `advantageAudience: 1` lets Meta expand delivery beyond your targeting. Pozy sends `0` for you unless you set it. - `budgetLevel: "campaign"` turns on Advantage campaign budget. - `promotion` attaches an offer (`PERCENTAGE_OFF`, `AMOUNT_OFF`, `FREE_SHIPPING`, `FREE_RETURN`, `PROMO_CODE`) to the creative. Check `ad.creative.promotionStatus` on a later read to see whether Meta kept it. ## Conversation and call ads `POST /v1/ads/messaging` creates an ad that opens a chat instead of a web page. `destination` picks where: | `destination` | Opens in | Requires | | --- | --- | --- | | `whatsapp` | WhatsApp | The Page paired with a verified WhatsApp Business number. `whatsappPhoneNumber` picks one of several. | | `messenger` | The Page's Messenger inbox | A Page | | `instagram_direct` | Instagram Direct | An Instagram professional account linked to the Page | The ad set is built with conversation optimization and the matching destination type. Useful extras: - `welcomeMessage` replaces Meta's greeting and pre-fills the person's first message. - `workflowId` starts one of your [workflows](https://pozy.ai/docs/automations/workflows) in the conversation the click opens. - `existingPostId` or `objectStoryId` runs an organic post as the ad instead of a new creative. - `adSetId` adds creatives to a running conversation ad set without resetting its learning. `POST /v1/ads/call` builds a call ad that dials `phoneNumber` (E.164). `linkUrl` must be a real web page, not a `tel:` link. `POST /v1/ads/ctwa` is the older click-to-WhatsApp endpoint; it still works, but new code should use `/v1/ads/messaging` with `destination: "whatsapp"`. ## Creative testing Send `creatives[]` instead of the single-creative fields to get one campaign, one ad set and N ads that share the budget. The ads are named ` #1`, ` #2` and so on, and the response returns `ads[]` instead of `ad`. To add a variation later, call the create again with the ad set's id in `adSetId` and a single creative: it inherits the budget, targeting and schedule and keeps the learning Meta has done. Rank variations with the per-ad metrics on `GET /v1/ads/tree`, or split one ad's results by the asset Meta served with the breakdowns `image_asset`, `video_asset`, `body_asset` and `title_asset`. `GET /v1/ads/studies` lists the A/B tests and lift studies on the ad account. ### Partnership ads Partnership ads run a creator's Instagram post under your ad account, with both handles on the ad. Ask a creator for permission with `POST /v1/ads/partnership-permissions`, track who has granted it with `GET /v1/ads/partnership-permissions`, and find their eligible posts with `GET /v1/ads/partnership-content`. This is in private beta, so the calls can return `feature_not_available` until it opens to your workspace. ## Reach and frequency Reach and frequency buys lock a price for a fixed audience and frequency before you spend. ### Quote `POST /v1/ads/rf-predictions` with the targeting, explicit `placements`, the window and either `budgetAmount` (Meta predicts reach) or `reach` (Meta predicts budget). `frequencyCap` is 1 to 90. Show the returned minimum and maximum budget and reach. ### Reserve `POST /v1/ads/rf-predictions/{predictionId}/reserve` holds the inventory for about 14 days and returns a new prediction id. Release it with `DELETE /v1/ads/rf-predictions/{predictionId}`. ### Buy `POST /v1/ads/create` with `buyingType: "RESERVED"` and `rfPredictionId` set to the reserved id. Send no budget or bid fields: they come from the reservation. Targeting and placements must match the quote. ## Previews `GET /v1/ads/{adId}/preview?formats=INSTAGRAM_STORY,MOBILE_FEED_STANDARD` renders an existing ad. `POST /v1/ads/preview` renders a creative before it is on an ad, from `existingCreativeId` or a raw `creativeSpec`. Both return Meta's iframe HTML, one per format, up to 10 formats per call. Embed the snippet as it is and fetch it when the user opens the preview: the signed URL inside is short-lived. ## Ad comments `GET /v1/ads/{adId}/comments` reads the comments on an ad, including dark posts that never appeared on the Page. An ad that runs on both Facebook and Instagram has two threads: pick one with `placement=facebook` or `placement=instagram`. Reply through the normal [comments endpoints](https://pozy.ai/docs/inbox/comments) with the post id the response returns in `meta.effectiveStoryId`. Hiding and deleting through the ad endpoints is TikTok only and returns `501` on Meta. ## Manage running ads | Job | Call | | --- | --- | | Pause or resume a campaign, ad set or ad | `PUT /v1/ads/campaigns/{campaignId}/status`, `PUT /v1/ads/ad-sets/{adSetId}/status`, `PUT /v1/ads/{adId}/status` with `status` (`active` or `paused`) and `platform` | | Pause or resume up to 50 campaigns | `POST /v1/ads/campaigns/bulk-status` | | Change an ad set budget, bid, schedule or optimization | `PUT /v1/ads/ad-sets/{adSetId}`, delivery settings inside `platformSpecificData` | | Change a campaign budget (CBO) | `PUT /v1/ads/campaigns/{campaignId}` with `budget` | | Change targeting | `PUT /v1/ads/{adId}` with `targeting` (applies to the ad's ad set) | | Swap the creative | `PUT /v1/ads/{adId}` with `creative`. Meta creatives are immutable, so Pozy builds a new one and points the ad at it. The ad goes through review again. | | Duplicate | `POST /v1/ads/campaigns/{campaignId}/duplicate`, `/ad-sets/{adSetId}/duplicate`, `/{adId}/duplicate` | | Delete | `DELETE` on the campaign, ad set or ad. Deletes cascade down. | Each switch is independent. Pausing a campaign stops everything under it, but resuming it only restarts children whose own switch is on. When a status is already what you asked for, nothing is written and the response says `skipped: 1`. Duplicates default to a deep copy created paused (`statusOption: "PAUSED"`), so nothing spends until you activate it. `renameStrategy`, `renamePrefix` and `renameSuffix` name the copies, and the response maps each source id to its copy in `adSetIdMap` and `adIdMap`. A large campaign takes minutes because Meta copies one object at a time. `GET /v1/ads/ad-sets/{adSetId}` reads the ad set live from Meta, including `learning_stage_info.status` (`LEARNING`, `SUCCESS`, `FAIL`, `WAIVING`). A missing status means unknown, not "out of learning". ## Reporting | You want | Call | Cost to the account | | --- | --- | --- | | Spend, CPC, CPM, conversions and ROAS per level | `GET /v1/ads/tree`, `GET /v1/ads`, `GET /v1/ads/{adId}` | None, served from synced metrics | | A daily series | `GET /v1/ads/timeline` | None | | One ad or campaign split by a dimension | `GET /v1/ads/{adId}/analytics`, `GET /v1/ads/campaigns/{campaignId}/analytics` with `breakdowns` | One Meta call per dimension | | Any Meta field, breakdown or filter | `GET /v1/ads/insights` with `objectId`, `level`, `fields`, `breakdowns` | One live call | | Long ranges or whole accounts | `POST /v1/ads/insights/reports`, then poll `GET /v1/ads/insights/reports/{reportRunId}` | A Meta job | Breakdown dimensions: `age`, `gender`, `country`, `region`, `publisher_platform`, `platform_position`, `device_platform`, `impression_device`, `image_asset`, `video_asset`, `body_asset`, `title_asset`. Metrics on each node include `spend`, `impressions`, `reach`, `clicks`, `ctr`, `cpc`, `cpm`, `conversions`, `actions`, `actionValues`, `purchaseValue` and `roas`. Reach is de-duplicated within a day only, so never sum it across days. A freshly connected ad account can answer `202` with `backfillPending: true` while history loads: retry after the `Retry-After` delay. When polling an async report, stop on `Job Completed`, `Job Failed` or `Job Skipped`. Start at 5 seconds and back off to a minute. More in [Reporting](https://pozy.ai/docs/ads/reporting). ## Regulated ads - `specialAdCategories` declares housing, employment, credit, financial products, politics or gambling ads. Meta then restricts targeting (no income or ZIP targeting). - Ads delivered in the EU need `dsaBeneficiary` and `dsaPayor` unless the ad account has defaults (`GET /v1/ads/dsa-defaults`). - Other regimes (Brazil, Thailand, India and more) use `regionalRegulatedCategories` and `regionalRegulationIdentities`. ## Limits and errors | Error | Cause | Fix | | --- | --- | --- | | `422` `linked_account_required` | The account is an Instagram Login account | Use a `facebook` or `metaads` account from the same profile. | | `400` with `type: "platform_error"` | Meta refused the request. Meta's code and subcode are in `platformError`. | Read Meta's message: it usually names the field. | | `400` naming two fields | Two creative shapes that exclude each other | Send one media source per creative. | | `409` `BUDGET_LEVEL_MISMATCH` | You sent an ad set budget to a CBO campaign | Update the budget on the campaign instead. | | `409` on a boost or create retry | An identical request is still in flight | Wait, then read `GET /v1/ads`. Send an `Idempotency-Key`. | | `502` `video_thumbnail_unavailable` | Meta produced no poster for the video | Send `thumbnailUrl`. | Locations overlap is the most common targeting error: do not send a city together with the country that contains it. See [Targeting](https://pozy.ai/docs/ads/targeting#meta). ## Endpoints Campaigns, ad sets, ads and reporting: | Endpoint | What it does | | --- | --- | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/campaigns`](https://pozy.ai/docs/api/createAdCampaign) | Create a standalone campaign | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/campaigns`](https://pozy.ai/docs/api/listAdCampaigns) | List campaigns | | [`GET /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/getAdCampaignDetails) | Get live campaign details | | [`PUT /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/updateAdCampaign) | Update a campaign | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`POST /v1/ads/campaigns/bulk-status`](https://pozy.ai/docs/api/bulkUpdateAdCampaignStatus) | Pause or resume many campaigns | | [`POST /v1/ads/campaigns/{campaignId}/duplicate`](https://pozy.ai/docs/api/duplicateAdCampaign) | Duplicate a campaign | | [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign | | [`GET /v1/ads/ad-sets`](https://pozy.ai/docs/api/listAdSets) | List ad sets | | [`GET /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/getAdSetDetails) | Get live ad-set details | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set | | [`POST /v1/ads/ad-sets/{adSetId}/duplicate`](https://pozy.ai/docs/api/duplicateAdSet) | Duplicate an ad set | | [`DELETE /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/deleteAdSet) | Delete an ad set | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`POST /v1/ads/{adId}/duplicate`](https://pozy.ai/docs/api/duplicateAd) | Duplicate an ad | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`GET /v1/ads/{adId}/review`](https://pozy.ai/docs/api/getAdReview) | Read the platform's review verdict for an ad | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/campaigns/{campaignId}/analytics`](https://pozy.ai/docs/api/getCampaignAnalytics) | Get campaign analytics | | [`GET /v1/ads/timeline`](https://pozy.ai/docs/api/getAdsTimeline) | Get daily account metrics | | [`GET /v1/ads/insights`](https://pozy.ai/docs/api/queryAdInsights) | Flexible live insights query | | [`POST /v1/ads/insights/reports`](https://pozy.ai/docs/api/createAdInsightsReport) | Submit async insights report | | [`GET /v1/ads/insights/reports/{reportRunId}`](https://pozy.ai/docs/api/getAdInsightsReport) | Poll an async insights report run | Meta-only tools: | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/businesses`](https://pozy.ai/docs/api/listMetaBusinesses) | Businesses list | | [`GET /v1/ads/businesses/users`](https://pozy.ai/docs/api/listMetaBusinessUsers) | Business users | | [`POST /v1/ads/accounts`](https://pozy.ai/docs/api/createAdAccount) | Create ad account | | [`PATCH /v1/ads/accounts`](https://pozy.ai/docs/api/updateAdAccount) | Update ad account settings | | [`GET /v1/ads/accounts/health`](https://pozy.ai/docs/api/getAdAccountHealth) | Check an ad account before creating ads | | [`GET /v1/ads/accounts/finance`](https://pozy.ai/docs/api/getAdAccountFinance) | Ad account finances | | [`GET /v1/ads/instagram-accounts`](https://pozy.ai/docs/api/listAdsInstagramAccounts) | List Instagram ad identities | | [`GET /v1/ads/instagram-posts`](https://pozy.ai/docs/api/listAdsInstagramPosts) | List Instagram posts to boost | | [`GET /v1/ads/value-rule-sets`](https://pozy.ai/docs/api/listValueRuleSets) | List value rule sets | | [`POST /v1/ads/value-rule-sets`](https://pozy.ai/docs/api/createValueRuleSet) | Create a value rule set | | [`GET /v1/ads/value-rule-sets/{valueRuleSetId}`](https://pozy.ai/docs/api/getValueRuleSet) | Read a value rule set | | [`PUT /v1/ads/value-rule-sets/{valueRuleSetId}`](https://pozy.ai/docs/api/updateValueRuleSet) | Replace a value rule set | | [`DELETE /v1/ads/value-rule-sets/{valueRuleSetId}`](https://pozy.ai/docs/api/deleteValueRuleSet) | Delete a value rule set | | [`GET /v1/ads/studies`](https://pozy.ai/docs/api/listAdStudies) | A/B tests and lift studies | | [`GET /v1/ads/activity`](https://pozy.ai/docs/api/getAdsActivityLog) | Ad account change / audit log | | [`GET /v1/ads/high-demand-periods`](https://pozy.ai/docs/api/listHighDemandPeriods) | List high-demand periods | | [`POST /v1/ads/high-demand-periods`](https://pozy.ai/docs/api/createHighDemandPeriod) | Schedule a budget increase | | [`GET /v1/ads/advertisable-applications`](https://pozy.ai/docs/api/listAdvertisableApplications) | List advertisable apps | | [`GET /v1/ads/ios-fourteen-campaign-limits`](https://pozy.ai/docs/api/getIosFourteenCampaignLimits) | Get iOS 14 campaign limits | | [`GET /v1/ads/page-users`](https://pozy.ai/docs/api/listPageUsers) | Page users of a business | | [`POST /v1/ads/page-users`](https://pozy.ai/docs/api/assignPageUser) | Assign a user to a Page | | [`DELETE /v1/ads/page-users`](https://pozy.ai/docs/api/removePageUser) | Remove a user from a Page | | [`GET /v1/ads/dsa-defaults`](https://pozy.ai/docs/api/getDsaDefaults) | Get ad account DSA defaults | | [`GET /v1/ads/dsa-recommendations`](https://pozy.ai/docs/api/getDsaRecommendations) | Get DSA recommendations | | [`POST /v1/ads/preview`](https://pozy.ai/docs/api/generateAdPreviews) | Render pre-create ad previews | | [`GET /v1/ads/{adId}/preview`](https://pozy.ai/docs/api/getAdPreviews) | Render previews of an existing ad | | [`GET /v1/ads/{adId}/media`](https://pozy.ai/docs/api/getAdMedia) | Direct video and image URLs for an ad | | [`GET /v1/ads/creatives`](https://pozy.ai/docs/api/listAdCreatives) | Creative library | | [`POST /v1/ads/creatives`](https://pozy.ai/docs/api/createAdCreative) | Create a standalone creative | | [`GET /v1/ads/images`](https://pozy.ai/docs/api/listAdImages) | Ad image library | | [`POST /v1/ads/images`](https://pozy.ai/docs/api/uploadAdImage) | Upload an ad image from base64 | | [`GET /v1/ads/videos`](https://pozy.ai/docs/api/listAdVideos) | Ad video library | | [`POST /v1/ads/videos`](https://pozy.ai/docs/api/uploadAdVideo) | Upload an ad video | | [`GET /v1/ads/{adId}/comments`](https://pozy.ai/docs/api/getAdComments) | List comments on an ad | | [`GET /v1/ads/partnership-content`](https://pozy.ai/docs/api/listPartnershipAdContent) | List partnership ad content | | [`GET /v1/ads/partnership-permissions`](https://pozy.ai/docs/api/listPartnershipAdPermissions) | List partnership permissions | | [`POST /v1/ads/partnership-permissions`](https://pozy.ai/docs/api/setPartnershipAdPermission) | Set partnership permission | | [`GET /v1/ads/library`](https://pozy.ai/docs/api/searchAdLibrary) | Search the public Ad Library | Conversation ads and reach and frequency: | Endpoint | What it does | | --- | --- | | [`POST /v1/ads/messaging`](https://pozy.ai/docs/api/createMessagingAd) | Create messaging ad | | [`POST /v1/ads/call`](https://pozy.ai/docs/api/createCallAd) | Create Click-to-Call ad | | [`POST /v1/ads/ctwa`](https://pozy.ai/docs/api/createCtwaAd) | Create CTWA ad (deprecated) | | [`POST /v1/ads/rf-predictions`](https://pozy.ai/docs/api/createRfPrediction) | Create reach-frequency prediction | | [`GET /v1/ads/rf-predictions/{predictionId}`](https://pozy.ai/docs/api/getRfPrediction) | Get reach-frequency prediction | | [`DELETE /v1/ads/rf-predictions/{predictionId}`](https://pozy.ai/docs/api/cancelRfReservation) | Cancel reach-frequency booking | | [`POST /v1/ads/rf-predictions/{predictionId}/reserve`](https://pozy.ai/docs/api/reserveRfPrediction) | Reserve reach-frequency inventory | --- # Google Ads Create Search, Display, Performance Max and Demand Gen campaigns, manage keywords and assets, apply recommendations and query GAQL from the API. A `googleads` connection lets you build Google Search, Display, Performance Max and Demand Gen campaigns, run Keyword Planner, upload Customer Match lists, choose conversion goals, act on Google's recommendations and query anything Google reporting can answer. Campaigns of every type, including Shopping and Video ones made in Google Ads, sync into the [campaign tree](https://pozy.ai/docs/ads/reporting) with metrics. ## Connect Call `GET /v1/connect/googleads/ads` with the `profileId`. Google Ads is its own OAuth connection, separate from YouTube and Google Business Profile. The first call returns an `authUrl`; once the user consents, the same call returns `alreadyConnected: true` and the `accountId`. You do not need a Google Ads developer token or an API access application of your own. Accounts under a manager (MCC) account work too. `GET /v1/ads/accounts?accountId=...` lists the customer ids the connection reaches. Use the id without dashes as `adAccountId`. `GET /v1/ads/accounts/hierarchy` shows how the accounts nest under managers. | Id | What it is on Google | | --- | --- | | `adAccountId` | Customer id, digits only | | `platformCampaignId` / `campaignId` | Campaign id | | `platformAdSetId` / `adSetId` | Ad group id | | `platformAdId` | Ad id. On Performance Max, Pozy lists the asset group as the ad. | ## Create a Search campaign `campaignType` picks the campaign: `search`, `display`, `pmax` or `demand_gen`. A Search create needs the ad copy and `keywords`. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: search-plumbing-austin" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c3e", "adAccountId": "1234567890", "campaignType": "search", "name": "Emergency plumbing - Austin", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "24/7 Emergency Plumber", "body": "Licensed plumbers at your door in 60 minutes. Upfront prices.", "additionalHeadlines": [ "Same-Day Repairs", { "text": "Austin Plumbing Pros", "pinnedField": "HEADLINE_1" } ], "additionalDescriptions": ["No call-out fee before 8pm. Book online in two minutes."], "linkUrl": "https://plumbing.example.com/austin", "keywords": [ "emergency plumber austin", { "text": "burst pipe repair", "matchType": "phrase" } ], "countries": ["US"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "search-plumbing-austin", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c3e", adAccountId: "1234567890", campaignType: "search", name: "Emergency plumbing - Austin", goal: "traffic", budgetAmount: 50, budgetType: "daily", headline: "24/7 Emergency Plumber", body: "Licensed plumbers at your door in 60 minutes. Upfront prices.", additionalHeadlines: ["Same-Day Repairs", { text: "Austin Plumbing Pros", pinnedField: "HEADLINE_1" }], additionalDescriptions: ["No call-out fee before 8pm. Book online in two minutes."], linkUrl: "https://plumbing.example.com/austin", keywords: ["emergency plumber austin", { text: "burst pipe repair", matchType: "phrase" }], countries: ["US"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.platformAdSetId); // the ad group id ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "search-plumbing-austin", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c3e", "adAccountId": "1234567890", "campaignType": "search", "name": "Emergency plumbing - Austin", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "24/7 Emergency Plumber", "body": "Licensed plumbers at your door in 60 minutes. Upfront prices.", "additionalHeadlines": ["Same-Day Repairs", {"text": "Austin Plumbing Pros", "pinnedField": "HEADLINE_1"}], "additionalDescriptions": ["No call-out fee before 8pm. Book online in two minutes."], "linkUrl": "https://plumbing.example.com/austin", "keywords": ["emergency plumber austin", {"text": "burst pipe repair", "matchType": "phrase"}], "countries": ["US"], }, timeout=60, ) resp.raise_for_status() print(resp.json()["ad"]["_id"]) ``` > **Budgets are not micros:** > > Amounts are in the account's currency units: on a USD account, `budgetAmount: 50` with `budgetType: "daily"` means $50.00 per day. Google's API and GAQL reports count in micros instead, so a figure pasted from a report is off by a factor of one million. Rules for the Search create: - `goal` is `engagement`, `traffic` or `awareness`. `video_views` and conversion goals return `422`. - `keywords` are broad match unless you send `{ text, matchType }` with `exact`, `phrase` or `broad`. Up to 1,000 per call, 80 characters each. `negativeKeywords` and `campaignNegativeKeywords` take the same shape. - The responsive search ad ends up with 3 to 15 headlines (30 characters each) and 2 to 4 descriptions (90 characters each) after `headline`, `body`, `additionalHeadlines` and `additionalDescriptions` are combined. `pinnedField` fixes one to `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3`, `DESCRIPTION_1` or `DESCRIPTION_2`. - `sitelinks`, `callouts` and `structuredSnippets` attach assets to the new campaign in the same call. - Every Google create starts as `pending_review`. Later syncs move it to `active`, `rejected`, `paused`, `completed` or `cancelled`. - `validateOnly: true` checks the whole request against Google without creating anything. ## Display campaigns Send `campaignType: "display"`, `businessName` (25 characters) and both images: ```json { "campaignType": "display", "businessName": "Austin Plumbing", "headline": "Fast, fair plumbing", "longHeadline": "Licensed Austin plumbers, same-day repairs, upfront prices", "body": "Book online in two minutes.", "images": { "landscape": "https://cdn.example.com/plumbing-1200x628.jpg", "square": "https://cdn.example.com/plumbing-1080.jpg" } } ``` Google requires a landscape (1.91:1) and a square (1:1) image, JPEG or PNG up to 5,120 KB. Sending only one fails with `NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET`. `longHeadline` defaults to `headline`. ## Performance Max Send `campaignType: "pmax"` with an `assetGroup`. The campaign, its budget and the asset group with every asset are created in one atomic request. ```json { "campaignType": "pmax", "budgetAmount": 80, "budgetType": "daily", "assetGroup": { "name": "All services", "finalUrl": "https://plumbing.example.com", "businessName": "Austin Plumbing", "headlines": ["Emergency plumber", "Same-day repairs", "Upfront prices"], "longHeadline": "Licensed Austin plumbers at your door in 60 minutes", "descriptions": ["Book online in two minutes.", "No call-out fee before 8pm."], "images": { "landscape": ["https://cdn.example.com/pm-1200x628.jpg"], "square": ["https://cdn.example.com/pm-1080.jpg"], "logo": ["https://cdn.example.com/logo-512.png"] }, "youtubeVideoId": "dQw4w9WgXcQ" } } ``` | Rule | Detail | | --- | --- | | Starts paused | Google will not create a Performance Max campaign that is already running. Once it passes review, switch it on with `PUT /v1/ads/campaigns/{campaignId}/status`. | | Daily budget only | Send `budgetType: "daily"`; nothing else is accepted. | | Bidding | Either maximize conversions or maximize conversion value. Bid caps and portfolio strategies get an error. | | Targeting | Locations, excluded locations and languages only. No geo means everywhere. | | Asset minimums | At least 3 headlines, 2 descriptions, and landscape, square and logo images. | After launch, manage asset groups under `/v1/ads/campaigns/{campaignId}/asset-groups`: list them with Google's ad strength (`POOR` to `EXCELLENT`), add groups, edit URLs and status, link and unlink assets in one atomic request, and replace the retail listing-group tree for Merchant Center campaigns. An asset group's status is separate from its campaign's: an enabled group in a paused campaign spends nothing. ## Demand Gen Demand Gen serves on YouTube (in-stream, in-feed, Shorts), Discover, Gmail and display partners. Send `campaignType: "demand_gen"` with a `demandGen` object. The format follows from what you send: | Format | Selected by | Creative | | --- | --- | --- | | Multi-asset image | Default | 1 to 5 `headlines` (40 characters), 1 to 5 `descriptions` (90), `businessName`, landscape or square images plus a logo | | Video | `youtubeVideoIds` (1 to 5) | `headlines`, `longHeadlines` (required here), `descriptions`, `businessName`, one logo | | Carousel | `carouselCards` (2 to 10) | One headline, description and logo; each card has its own headline and image | To restrict where a Demand Gen campaign shows, list channels in `demandGen.channels`, choosing from `youtube_in_stream`, `youtube_in_feed`, `youtube_shorts`, `discover`, `gmail` and `display`. Describe the audience in `demandGen.audience` (user lists, interests, custom audiences, age ranges, genders), or point `demandGen.audienceId` at one you already have. These campaigns are created paused, and their locations and languages are set per ad group. The same create endpoint adds more: an ad group with `existingCampaignId`, an ad with `adSetId`. ## Bidding and budgets `bidStrategy` uses the same names as the other networks and maps to Google's strategies: | `bidStrategy` | With | Google strategy | | --- | --- | --- | | `LOWEST_COST_WITHOUT_CAP` | | Maximize conversions | | `COST_CAP` | `bidAmount` | Target CPA | | `LOWEST_COST_WITH_MIN_ROAS` | `roasAverageFloor` | Target ROAS | | `LOWEST_COST_WITH_BID_CAP` | `bidAmount` | Maximize clicks with a CPC ceiling | Other ways to bid, in place of `bidStrategy`: a shared portfolio strategy through `portfolioBidStrategyId` (ids come from `GET /v1/ads/bid-strategies`), `targetImpressionShare` for Search, and `manualCpc: { maxCpc }` for Search or Display. Use `networkSettings` to include or exclude search partners and the display network. Google budgets are daily. `PUT /v1/ads/campaigns/{campaignId}` with `budget` changes one. A shared budget changes every campaign on it, so the call returns `409` unless you send `allowSharedBudgetUpdate: true`. `sharedBudgetId` moves a campaign onto a budget from `GET /v1/ads/shared-budgets`. ## Keywords | Job | Call | | --- | --- | | Read the keywords your ad groups run, with quality score and 30-day metrics | `GET /v1/ads/keywords` (filters: `campaignId`, `adSetId`, `status`, `matchType`, `negative`, `search`) | | Add keywords to an ad group | `POST /v1/ads/keywords` with `adSetId` and `keywords` (`negative: true` for negatives) | | Pause or enable one keyword | `PATCH /v1/ads/keywords/{keywordId}` | | Remove one | `DELETE /v1/ads/keywords/{keywordId}` | | Replace a campaign's negatives | `PUT /v1/ads/campaigns/{campaignId}/negative-keywords` (up to 1,000) | | Shared negative lists | `/v1/ads/accounts/negative-keyword-lists` (up to 5,000 keywords each, 20 lists per campaign) | | Keyword ideas | `POST /v1/ads/keywords/ideas` with `seedKeywords`, `seedUrl` or both | | Historical volume and bids | `POST /v1/ads/keywords/historical-metrics` (up to 1,000 keywords) | `keywordId` takes Pozy's id or Google's `{adSetId}~{criterionId}`. A bare criterion id is rejected because it is only unique inside its ad group. The synced keyword list refreshes about once a day, so keywords added in Google Ads can take a day to appear. Keyword Planner rows come back in Google's raw shape: counters are strings and bids are micros. ## Assets, labels and accounts - **Sitelinks, callouts and structured snippets** exist at account, campaign and ad group level. Create and attach them with the `/v1/ads/accounts/sitelinks` family, `POST /v1/ads/campaigns/{campaignId}/assets` or `POST /v1/ads/ad-sets/{adSetId}/assets`. Updating an asset edits it everywhere it is attached. Removing only detaches it, because Google assets cannot be deleted. - **Labels** organize campaigns, ad groups, ads and keywords: `POST /v1/ads/labels`, then attach with `POST /v1/ads/labels/{labelId}/assignments`. - **Manager accounts**: create a client account under a manager with `POST /v1/ads/accounts`, bill it to the manager's monthly invoicing with `POST /v1/ads/accounts/billing`, invite a client account to a manager with `POST /v1/ads/accounts/manager-links`, and accept, decline or end links with `PATCH`. Ads do not serve until the account has billing. - **User access**: invite people to an ad account by email with `POST /v1/ads/accounts/user-invitations`. - **Conversion goals**: read and change which conversion actions a campaign bids on with `/v1/ads/campaigns/{campaignId}/conversion-goals`. Sending conversions is covered in [Conversions](https://pozy.ai/docs/ads/conversions). ## Recommendations `GET /v1/ads/recommendations` lists Google's suggestions (budgets, bidding targets, keywords, assets) with `impact.base` and `impact.potential` estimates. Filter by `campaignId` or `types`. `POST /v1/ads/recommendations/apply` applies up to 100, with Google's suggested values or your own in `parameters`. `POST /v1/ads/recommendations/dismiss` stops Google suggesting them. > **Applying cannot be undone:** > > Applying changes budgets, bids, keywords or assets, has no dry run and is not idempotent. Each item reports `applied` or `failed` on its own, and the call returns `200` even when some fail. Check every result. ## Targeting Locations, languages, age, gender, the `top_10` income tier, interests and Customer Match lists. Google has no city radius: use a `customLocations` pin instead. `locationTargetingType` decides whether locations reach people who are there (`presence`) or also people interested in them (`presence_or_interest`, Google's default). `PUT /v1/ads/campaigns/{campaignId}/targeting` changes a live campaign's devices, locations, exclusions and languages. Full rules are in [Targeting](https://pozy.ai/docs/ads/targeting#google). ## Reporting | You want | Call | | --- | --- | | Spend, clicks, CPC, CPM and conversions per campaign, ad group and ad | `GET /v1/ads/tree?accountId=...&platform=google` | | Budget metadata and Search impression share for a campaign | `GET /v1/ads/campaigns/{campaignId}/analytics` | | The search terms that triggered your ads, by cost | `GET /v1/ads/search-terms` | | Anything else Google reporting answers | `GET /v1/ads/insights?accountId=...&query=SELECT ...` | | Local Services leads and their conversations | `GET /v1/ads/local-services/leads` | With `GET /v1/ads/insights` you can run your own read-only GAQL `SELECT`. Rows come back exactly as Google returns them, up to 10,000 a page; pass `paging.nextPageToken` as `pageToken` for the next one. Selecting `segments.date` requires a bounded date range in the query, for example `DURING LAST_30_DAYS`. ```bash curl -G https://api.pozy.ai/v1/ads/insights \ -H "Authorization: Bearer $POZY_API_KEY" \ --data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c3e" \ --data-urlencode "query=SELECT campaign.name, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_30_DAYS ORDER BY metrics.cost_micros DESC" ``` Impression share fields are ratios: `0.42` is 42%. Pozy imports the last 90 days of metrics when an account connects. For older days, query GAQL directly. ## Limits and errors Live calls to Google (GAQL, search terms, Keyword Planner, keyword and asset writes, recommendations) share a burst limit of 15 requests a minute per user and 120 across your accounts, on top of your [Pozy rate limit](https://pozy.ai/docs/getting-started/rate-limits). Cached reads (asset groups, recommendations, shared lists) carry `cachedAt` and `stale`: `stale: true` means Google throttled the request and you are reading the last good copy. | Error | Cause | Fix | | --- | --- | --- | | `422` `feature_not_available` | `goal: "video_views"` or a conversion goal on create | Use `engagement`, `traffic` or `awareness`. | | `400` `NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET` | Display create with one image | Send both `images.landscape` and `images.square`. | | `400` on headlines or descriptions | More than 15 headlines or 4 descriptions after deduplication | Trim the lists. | | `409` on a budget change | Several campaigns share this budget | Add `allowSharedBudgetUpdate: true` when you do want all of them changed. | | `429` with `details.resetsAt` | The burst limit or Google's quota | Retry after the time given. | | `400` `invalid_field_value` on `query` | Google rejected the GAQL | Google's message names the field. | Redirects after consent can carry `error=google_ads_auth_failed` (the user declined) or `error=google_ads_quota_exhausted` (retry later). ## Endpoints Google-only endpoints: | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/accounts/hierarchy`](https://pozy.ai/docs/api/getAdAccountHierarchy) | Get manager account hierarchy | | [`POST /v1/ads/accounts/manager-links`](https://pozy.ai/docs/api/inviteAdAccountToManager) | Invite a client account to a manager | | [`PATCH /v1/ads/accounts/manager-links`](https://pozy.ai/docs/api/updateAdAccountManagerLink) | Accept, decline, cancel or end a manager link | | [`GET /v1/ads/accounts/billing`](https://pozy.ai/docs/api/getAdAccountBilling) | Get ad account billing | | [`POST /v1/ads/accounts/billing`](https://pozy.ai/docs/api/setUpAdAccountBilling) | Set up ad account billing (monthly invoicing) | | [`GET /v1/ads/accounts/user-invitations`](https://pozy.ai/docs/api/listAdAccountUserInvitations) | List user access invitations | | [`POST /v1/ads/accounts/user-invitations`](https://pozy.ai/docs/api/inviteAdAccountUser) | Invite a user to an ad account by email | | [`DELETE /v1/ads/accounts/user-invitations`](https://pozy.ai/docs/api/revokeAdAccountUserInvitation) | Revoke a user access invitation | | [`GET /v1/ads/accounts/user-invitations/pending-approvals`](https://pozy.ai/docs/api/listAdAccountUserInvitationApprovals) | List invitations awaiting multi-party approval | | [`DELETE /v1/ads/accounts/user-invitations/pending-approvals`](https://pozy.ai/docs/api/withdrawAdAccountUserInvitationApproval) | Withdraw an invitation awaiting multi-party approval | | [`GET /v1/ads/recommendations`](https://pozy.ai/docs/api/listGoogleRecommendations) | List Google Ads recommendations | | [`POST /v1/ads/recommendations/apply`](https://pozy.ai/docs/api/applyGoogleRecommendations) | Apply Google Ads recommendations | | [`POST /v1/ads/recommendations/dismiss`](https://pozy.ai/docs/api/dismissGoogleRecommendations) | Dismiss Google Ads recommendations | | [`GET /v1/ads/bid-strategies`](https://pozy.ai/docs/api/listBidStrategies) | List portfolio bid strategies | | [`POST /v1/ads/bid-strategies`](https://pozy.ai/docs/api/createBidStrategy) | Create portfolio bid strategy | | [`PATCH /v1/ads/bid-strategies/{strategyId}`](https://pozy.ai/docs/api/updateBidStrategy) | Update portfolio bid strategy | | [`GET /v1/ads/shared-budgets`](https://pozy.ai/docs/api/listSharedBudgets) | List shared budgets | | [`POST /v1/ads/shared-budgets`](https://pozy.ai/docs/api/createSharedBudget) | Create a shared budget | | [`GET /v1/ads/keywords`](https://pozy.ai/docs/api/listAdKeywords) | List Search keywords | | [`POST /v1/ads/keywords`](https://pozy.ai/docs/api/addAdKeywords) | Add Search ad-group keywords | | [`PATCH /v1/ads/keywords/{keywordId}`](https://pozy.ai/docs/api/updateAdKeyword) | Pause or enable a Search keyword | | [`DELETE /v1/ads/keywords/{keywordId}`](https://pozy.ai/docs/api/removeAdKeyword) | Remove a Search keyword | | [`GET /v1/ads/campaigns/{campaignId}/negative-keywords`](https://pozy.ai/docs/api/listCampaignNegativeKeywords) | List campaign-level negative keywords | | [`PUT /v1/ads/campaigns/{campaignId}/negative-keywords`](https://pozy.ai/docs/api/replaceCampaignNegativeKeywords) | Replace campaign-level negative keywords | | [`GET /v1/ads/accounts/negative-keyword-lists`](https://pozy.ai/docs/api/listAdNegativeKeywordLists) | List negative keyword lists | | [`POST /v1/ads/accounts/negative-keyword-lists`](https://pozy.ai/docs/api/createAdNegativeKeywordList) | Create a negative keyword list | | [`GET /v1/ads/accounts/negative-keyword-lists/{listId}`](https://pozy.ai/docs/api/getAdNegativeKeywordList) | Get a negative keyword list | | [`PUT /v1/ads/accounts/negative-keyword-lists/{listId}`](https://pozy.ai/docs/api/updateAdNegativeKeywordList) | Rename a negative keyword list | | [`DELETE /v1/ads/accounts/negative-keyword-lists/{listId}`](https://pozy.ai/docs/api/deleteAdNegativeKeywordList) | Delete a negative keyword list | | [`PUT /v1/ads/accounts/negative-keyword-lists/{listId}/keywords`](https://pozy.ai/docs/api/replaceAdNegativeKeywordListKeywords) | Replace negative list keywords | | [`GET /v1/ads/campaigns/{campaignId}/negative-keyword-lists`](https://pozy.ai/docs/api/listCampaignNegativeKeywordLists) | List campaign negative lists | | [`PUT /v1/ads/campaigns/{campaignId}/negative-keyword-lists`](https://pozy.ai/docs/api/replaceCampaignNegativeKeywordLists) | Replace campaign negative lists | | [`POST /v1/ads/keywords/ideas`](https://pozy.ai/docs/api/generateKeywordIdeas) | Generate keyword ideas | | [`POST /v1/ads/keywords/historical-metrics`](https://pozy.ai/docs/api/generateKeywordHistoricalMetrics) | Get historical keyword metrics | | [`GET /v1/ads/search-terms`](https://pozy.ai/docs/api/getAdsSearchTerms) | Google Ads search terms report | | [`GET /v1/ads/campaigns/{campaignId}/asset-groups`](https://pozy.ai/docs/api/listGoogleAssetGroups) | List Performance Max asset groups | | [`POST /v1/ads/campaigns/{campaignId}/asset-groups`](https://pozy.ai/docs/api/createGoogleAssetGroup) | Create a Performance Max asset group | | [`GET /v1/ads/campaigns/{campaignId}/asset-groups/{assetGroupId}`](https://pozy.ai/docs/api/getGoogleAssetGroup) | Get a Performance Max asset group | | [`PATCH /v1/ads/campaigns/{campaignId}/asset-groups/{assetGroupId}`](https://pozy.ai/docs/api/updateGoogleAssetGroup) | Update a Performance Max asset group | | [`DELETE /v1/ads/campaigns/{campaignId}/asset-groups/{assetGroupId}`](https://pozy.ai/docs/api/removeGoogleAssetGroup) | Remove a Performance Max asset group | | [`POST /v1/ads/campaigns/{campaignId}/asset-groups/{assetGroupId}/assets`](https://pozy.ai/docs/api/editGoogleAssetGroupAssets) | Link or unlink asset group assets | | [`PUT /v1/ads/campaigns/{campaignId}/asset-groups/{assetGroupId}/listing-group-filters`](https://pozy.ai/docs/api/replaceGoogleListingGroupFilters) | Replace an asset group's listing-group tree | | [`GET /v1/ads/campaigns/{campaignId}/conversion-goals`](https://pozy.ai/docs/api/getCampaignConversionGoals) | Get campaign conversion goals | | [`PATCH /v1/ads/campaigns/{campaignId}/conversion-goals`](https://pozy.ai/docs/api/updateCampaignConversionGoals) | Update campaign conversion goals | | [`GET /v1/ads/campaigns/{campaignId}/targeting`](https://pozy.ai/docs/api/getCampaignTargeting) | Read a Google campaign's device, location, excluded location, and language targeting | | [`PUT /v1/ads/campaigns/{campaignId}/targeting`](https://pozy.ai/docs/api/updateCampaignTargeting) | Edit a Google campaign's device, location, excluded location, or language targeting | | [`GET /v1/ads/campaigns/{campaignId}/ad-schedule`](https://pozy.ai/docs/api/getCampaignAdSchedule) | Read a campaign's ad schedule (dayparting) | | [`PUT /v1/ads/campaigns/{campaignId}/ad-schedule`](https://pozy.ai/docs/api/updateCampaignAdSchedule) | Replace a campaign's ad schedule (dayparting) | | [`GET /v1/ads/campaigns/{campaignId}/bidding`](https://pozy.ai/docs/api/getCampaignBidding) | Read a campaign's current bidding | | [`POST /v1/ads/ad-sets`](https://pozy.ai/docs/api/createAdSet) | Create a standalone ad group | | [`GET /v1/ads/local-services/leads`](https://pozy.ai/docs/api/listLocalServicesLeads) | Google Local Services Ads leads | | [`GET /v1/ads/local-services/leads/{leadId}/conversations`](https://pozy.ai/docs/api/listLocalServicesLeadConversations) | List lead conversations | Assets and labels: | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/accounts/sitelinks`](https://pozy.ai/docs/api/listAccountSitelinks) | List account sitelinks | | [`POST /v1/ads/accounts/sitelinks`](https://pozy.ai/docs/api/addAccountSitelinks) | Add account sitelinks | | [`PUT /v1/ads/accounts/sitelinks`](https://pozy.ai/docs/api/updateAccountSitelinks) | Update account sitelinks | | [`DELETE /v1/ads/accounts/sitelinks`](https://pozy.ai/docs/api/removeAccountSitelink) | Remove account sitelink | | [`GET /v1/ads/accounts/callouts`](https://pozy.ai/docs/api/listAccountCallouts) | List account callouts | | [`POST /v1/ads/accounts/callouts`](https://pozy.ai/docs/api/addAccountCallouts) | Add account callouts | | [`PUT /v1/ads/accounts/callouts`](https://pozy.ai/docs/api/updateAccountCallouts) | Update account callouts | | [`DELETE /v1/ads/accounts/callouts`](https://pozy.ai/docs/api/removeAccountCallout) | Remove account callout | | [`GET /v1/ads/accounts/structured-snippets`](https://pozy.ai/docs/api/listAccountStructuredSnippets) | List account snippets | | [`POST /v1/ads/accounts/structured-snippets`](https://pozy.ai/docs/api/addAccountStructuredSnippets) | Add account snippets | | [`PUT /v1/ads/accounts/structured-snippets`](https://pozy.ai/docs/api/updateAccountStructuredSnippets) | Update account snippets | | [`DELETE /v1/ads/accounts/structured-snippets`](https://pozy.ai/docs/api/removeAccountStructuredSnippet) | Remove account snippet | | [`GET /v1/ads/campaigns/{campaignId}/assets`](https://pozy.ai/docs/api/listCampaignAssets) | List campaign assets | | [`POST /v1/ads/campaigns/{campaignId}/assets`](https://pozy.ai/docs/api/attachCampaignAssets) | Attach campaign assets | | [`PUT /v1/ads/campaigns/{campaignId}/assets`](https://pozy.ai/docs/api/updateCampaignAssets) | Update campaign assets | | [`DELETE /v1/ads/campaigns/{campaignId}/assets`](https://pozy.ai/docs/api/removeCampaignAssets) | Remove campaign assets | | [`GET /v1/ads/ad-sets/{adSetId}/assets`](https://pozy.ai/docs/api/listAdGroupAssets) | List ad-group assets | | [`POST /v1/ads/ad-sets/{adSetId}/assets`](https://pozy.ai/docs/api/attachAdGroupAssets) | Attach ad-group assets | | [`PUT /v1/ads/ad-sets/{adSetId}/assets`](https://pozy.ai/docs/api/updateAdGroupAssets) | Update ad-group assets | | [`DELETE /v1/ads/ad-sets/{adSetId}/assets`](https://pozy.ai/docs/api/removeAdGroupAssets) | Remove ad-group assets | | [`GET /v1/ads/labels`](https://pozy.ai/docs/api/listAdLabels) | List ad labels | | [`POST /v1/ads/labels`](https://pozy.ai/docs/api/createAdLabel) | Create a Google Ads label | | [`PATCH /v1/ads/labels/{labelId}`](https://pozy.ai/docs/api/updateAdLabel) | Update a Google Ads label | | [`DELETE /v1/ads/labels/{labelId}`](https://pozy.ai/docs/api/removeAdLabel) | Remove a Google Ads label | | [`POST /v1/ads/labels/{labelId}/assignments`](https://pozy.ai/docs/api/attachAdLabel) | Attach a Google Ads label | | [`DELETE /v1/ads/labels/{labelId}/assignments`](https://pozy.ai/docs/api/detachAdLabel) | Detach a Google Ads label | The shared campaign, ad and reporting endpoints are listed on the [ads overview](https://pozy.ai/docs/ads#endpoints). --- # TikTok Ads Boost TikTok videos as Spark Ads, create video campaigns with pixel conversions, manage ad comments and run live TikTok reports. A `tiktokads` connection runs TikTok's Business API: Spark Ads that promote organic videos, standalone video campaigns, website conversion campaigns on a TikTok pixel, customer list audiences, ad comment moderation and live reports with de-duplicated reach. Agencies get every advertiser under their Business Centers from one connection. ## Connect Call `GET /v1/connect/tiktok/ads` with the `profileId`. TikTok Ads is a separate OAuth from TikTok posting, and TikTok grants its permissions at the app level, so there is no scope list to pick. You do not need your own TikTok developer onboarding. | Connect with | What you get | | --- | --- | | `accountId` of a connected TikTok posting account | The ads connection links to it. Spark Ads and standalone ads can run as the real `@username`. | | No `accountId` (ads-only) | Standalone ads run under a brand identity you configure. Spark Ads need the creator's Spark Code. | Set the ads-only brand identity once with `PATCH /v1/connect/tiktok-ads`, or per ad with `brandIdentity: { displayName, imageUrl }` on the create. `GET /v1/ads/tiktok-identities` lists the identities an advertiser can run ads as. `GET /v1/ads/accounts?accountId=...` returns every advertiser the connection reaches, including all advertisers under the Business Centers the user owns. `GET /v1/ads/business-centers` lists the Business Centers themselves with an advertiser count each, for an agency picker. The advertiser list is cached for an hour. ## Create a video campaign TikTok's ad creative is video. On `POST /v1/ads/create`, the video URL goes in `imageUrl` (the field keeps its name across networks), `body` is the caption, and `headline` is ignored because TikTok ads have no headline slot. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: tt-summer-drop-1" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c4f", "adAccountId": "7301234567890123456", "name": "Summer drop - US 18-34", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "body": "The summer drop is live. Link in bio, or tap below.", "imageUrl": "https://cdn.example.com/summer-drop-9x16.mp4", "linkUrl": "https://shop.example.com/summer", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 18, "ageMax": 34 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "tt-summer-drop-1", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c4f", adAccountId: "7301234567890123456", name: "Summer drop - US 18-34", goal: "traffic", budgetAmount: 50, budgetType: "daily", body: "The summer drop is live. Link in bio, or tap below.", imageUrl: "https://cdn.example.com/summer-drop-9x16.mp4", linkUrl: "https://shop.example.com/summer", callToAction: "SHOP_NOW", countries: ["US"], ageMin: 18, ageMax: 34, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.platformAdSetId); // the TikTok ad group ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "tt-summer-drop-1", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c4f", "adAccountId": "7301234567890123456", "name": "Summer drop - US 18-34", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "body": "The summer drop is live. Link in bio, or tap below.", "imageUrl": "https://cdn.example.com/summer-drop-9x16.mp4", "linkUrl": "https://shop.example.com/summer", "callToAction": "SHOP_NOW", "countries": ["US"], "ageMin": 18, "ageMax": 34, }, timeout=120, ) resp.raise_for_status() print(resp.json()["ad"]["_id"]) ``` `goal` takes `engagement`, `traffic`, `awareness`, `video_views`, `lead_generation`, `conversions` or `app_promotion`. TikTok's minimum budget is $20 per ad group. `smartPlus: true` builds a Smart+ campaign; check each creative's review with `GET /v1/ads/{adId}/tiktok-smart-plus-review` and per-creative results with `GET /v1/ads/tiktok-smart-plus-materials`. `lead_generation` sends the click to your own form on `linkUrl`. TikTok instant forms are not supported. ### Conversion campaigns A website conversion campaign needs a TikTok pixel. Pass it in `promotedObject`, the same field Meta uses: ```json { "goal": "conversions", "promotedObject": { "pixelId": "7212345678901234567", "customEventType": "ON_WEB_ORDER" } } ``` | Field | Meaning | | --- | --- | | `pixelId` | The numeric pixel id, not the alphanumeric pixel code shown in Events Manager. List your pixels on [Pixels and tags](https://pozy.ai/docs/ads/pixels) to find it. | | `customEventType` | The event to optimize for, in TikTok's codes: `ON_WEB_ORDER` (complete payment), `INITIATE_ORDER`, `ON_WEB_CART`, `ON_WEB_REGISTER`, `ON_WEB_DETAIL`, `FORM`, `ON_WEB_SEARCH`, `ON_WEB_ADD_TO_WISHLIST`, `LANDING_PAGE_VIEW`. Optional for auto-bid. | If a conversion ad group already exists in TikTok Ads Manager, pass its id as `adSetId` instead: the new ad inherits the pixel and event. ## Spark Ads A Spark Ad promotes a published TikTok video and keeps the creator's handle, likes and comments. Call `POST /v1/ads/boost`: ```json { "postId": "65f1c0a9e2b5af0012ab34cd", "accountId": "66b2e19d8c3f5a7e9d0b1c4f", "adAccountId": "7301234567890123456", "name": "Spark - summer drop", "goal": "traffic", "budget": { "amount": 30, "type": "daily" }, "targeting": { "countries": ["US", "CA"] }, "linkUrl": "https://shop.example.com/summer", "callToAction": "SHOP_NOW" } ``` - `targeting.countries` is required, unless you attach to an existing Smart+ ad group with `adSetId`. - `linkUrl` and `callToAction` add a clickable destination. Without them the Spark Ad has no destination beyond the video itself. - To promote another creator's video, ask them for a Spark Code from the TikTok app's promote settings. Send it as `sparkAuthCode` with their video id in `platformPostId`. Several creators at once go in `sparkPosts[]`. - On an ads-only connection, `sparkAuthCode` and `platformPostId` are always required, because the connection has no TikTok identity of its own. More boost options across networks are on [Boost posts](https://pozy.ai/docs/ads/boost-posts). ## Bidding `bidStrategy` uses the shared names and maps to TikTok's bid types: | `bidStrategy` | Needs | TikTok bid type | | --- | --- | --- | | `LOWEST_COST_WITHOUT_CAP` (default) | | No bid, maximum delivery | | `LOWEST_COST_WITH_BID_CAP` | `bidAmount` | Custom bid | | `COST_CAP` | `bidAmount` | Custom bid (cost cap) | | `LOWEST_COST_WITH_MIN_ROAS` | `roasAverageFloor` | Minimum ROAS (value-optimized accounts only) | It works on create, boost and `PUT /v1/ads/ad-sets/{adSetId}`. Reads normalize TikTok's bid type back to these names. ## Media | Type | Format | Max size | Notes | | --- | --- | --- | --- | | Video | MP4, MOV, MPEG | 500 MB | 9:16 vertical, 720p or better, 5 to 60 seconds | | Image | JPEG, PNG | 30 MB | 1080 x 1920 for full screen | The video transfers in one request, so stay under 500 MB. URLs must be public. ## Manage running ads | Job | Call | | --- | --- | | Pause or resume | `PUT /v1/ads/campaigns/{campaignId}/status`, `/ad-sets/{adSetId}/status` or `/{adId}/status` with `platform: "tiktok"` | | Add an ad to an ad group built in TikTok Ads Manager | `POST /v1/ads/create` with `adSetId` (budget, targeting and goal are inherited) | | Swap the creative | `PUT /v1/ads/{adId}` with `creative`: `body` becomes the ad text, `linkUrl` the landing page, `videoUrl` uploads a new video | | Change targeting | `PUT /v1/ads/{adId}` with `targeting` | | Duplicate a campaign | `POST /v1/ads/campaigns/{campaignId}/duplicate` with `platform: "tiktok"`. The copy is created paused and keeps Spark Ad links. | | Change budget or bid | `PUT /v1/ads/ad-sets/{adSetId}` | Creating an ad group on its own is not available on TikTok. Use the create or boost calls, or attach to an existing ad group. ## Ad comments `GET /v1/ads/{adId}/comments` reads comments on a TikTok ad, Spark Ads and dark posts included. TikTok also supports moderation from the same endpoints: - `POST /v1/ads/{adId}/comments/{commentId}/reply` with `text` - `POST /v1/ads/{adId}/comments/{commentId}/hide` with `hidden: true` or `false` - `DELETE /v1/ads/{adId}/comments/{commentId}` for your own comments TikTok only looks for comments inside a date range of 30 days or less; without one you get the past 30 days. Give the range as `since` and `until` in `YYYY-MM-DD`, and reuse it when you reply to or hide a comment close to either end. Every comment says what you may do with it through `canReply`, `canDelete` and `canHide`. Pages can come back empty with `pagination.hasMore` still `true`, so follow the cursor until it is not. ## Targeting Countries, regions, cities, ZIP codes and metros (create only), age, gender, languages, interests, behaviors, income tier and customer list audiences. Behaviors such as `video:1913101` (watched or engaged with videos in a category in the last 15 days) or `creator:24001` (followed creators in a category) restrict delivery to the TikTok placement. `PUT /v1/ads/{adId}` with `targeting` updates the ad group; sending a location replaces both location and ZIP targeting. More on [Targeting](https://pozy.ai/docs/ads/targeting#tiktok). ## Reporting - `GET /v1/ads/{adId}/analytics` returns spend, impressions, clicks, CTR, CPM and video metrics. `breakdowns` takes `gender`, `age`, `country_code`, `platform`, `ac` (network type) and `language`. - `GET /v1/ads/tree` adds those metrics up for each campaign and ad group. - `GET /v1/ads/insights` asks TikTok for a report on the spot. Send `adAccountId`, a `dataLevel` (`AUCTION_ADVERTISER`, `AUCTION_CAMPAIGN`, `AUCTION_ADGROUP` or `AUCTION_AD`), between 1 and 4 `dimensions`, the `metrics`, and dates spanning 365 days or less. `reportType` takes `BASIC` or `AUDIENCE`. To count each person once across several campaigns, filter the set and group by `country_code`: ```bash curl -G https://api.pozy.ai/v1/ads/insights \ -H "Authorization: Bearer $POZY_API_KEY" \ --data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c4f" \ --data-urlencode "adAccountId=7301234567890123456" \ --data-urlencode "dataLevel=AUCTION_CAMPAIGN" \ --data-urlencode "dimensions=country_code" \ --data-urlencode "metrics=reach,impressions,spend" \ --data-urlencode 'filtering=[{"field":"campaign_ids","operator":"IN","value":["1790000000000001","1790000000000002"]}]' \ --data-urlencode "fromDate=2026-09-01" \ --data-urlencode "toDate=2026-09-30" ``` Rows come back as TikTok returns them, with metric values as strings. A filter finer than `dataLevel` (such as `ad_ids` at campaign level) returns `400`. Pozy imports the last 90 days of metrics when an account connects. Async report runs are Meta only. ## Limits and errors Not available on TikTok: catalog and TikTok Shop ads, instant forms, chunked video upload, standalone ad group creation, and the Meta-only tools (previews, creative library, reach and frequency, studies, async reports). | Error | Cause | Fix | | --- | --- | --- | | `422` `ads_connection_required` | The profile has no `tiktokads` account, or the TikTok user has not been authorized as an identity of the advertiser | Connect TikTok Ads, or authorize that identity in TikTok Ads Manager. | | `40002: Please select a pixel` | A `conversions` goal with no `promotedObject.pixelId` | Add the pixel's numeric id. | | `40002: pixel_id ... is not a valid integer string` | You sent the pixel code, not its number | Send the numeric `id` of the pixel. | | `422` `invalid_field_value` on `targeting.countries` | A boost without a country | Send at least one country. | | `400` `missing_required_field` on `sparkAuthCode` | An ads-only connection boosting without a Spark Code | Send `sparkAuthCode`, or connect the TikTok account. | | Budget rejected | Below $20 per ad group | Raise the budget. | | Audience not applied | A new customer list takes up to 48 hours to process | Wait, then target it. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`PATCH /v1/connect/tiktok-ads`](https://pozy.ai/docs/api/configureTikTokAdsBrandIdentity) | Set TikTok brand identity | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`GET /v1/ads/business-centers`](https://pozy.ai/docs/api/listAdsBusinessCenters) | List TikTok Business Centers | | [`GET /v1/ads/tiktok-identities`](https://pozy.ai/docs/api/listAdsTikTokIdentities) | List TikTok ad identities | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`POST /v1/ads/campaigns/{campaignId}/duplicate`](https://pozy.ai/docs/api/duplicateAdCampaign) | Duplicate a campaign | | [`GET /v1/ads/{adId}/comments`](https://pozy.ai/docs/api/getAdComments) | List comments on an ad | | [`POST /v1/ads/{adId}/comments/{commentId}/reply`](https://pozy.ai/docs/api/replyToAdComment) | Reply to an ad comment | | [`POST /v1/ads/{adId}/comments/{commentId}/hide`](https://pozy.ai/docs/api/hideAdComment) | Hide or unhide an ad comment | | [`DELETE /v1/ads/{adId}/comments/{commentId}`](https://pozy.ai/docs/api/deleteAdComment) | Delete an ad comment | | [`GET /v1/ads/{adId}/tiktok-smart-plus-review`](https://pozy.ai/docs/api/getTikTokSmartPlusAdReview) | Review status of each creative in a TikTok Smart+ ad | | [`GET /v1/ads/tiktok-smart-plus-reviews`](https://pozy.ai/docs/api/listTikTokSmartPlusReviews) | Review status of every TikTok Smart+ ad of an advertiser | | [`GET /v1/ads/tiktok-smart-plus-materials`](https://pozy.ai/docs/api/getTikTokSmartPlusMaterialReport) | Per-creative performance inside TikTok Smart+ ads | | [`GET /v1/ads/pixels`](https://pozy.ai/docs/api/listTikTokAdPixels) | List TikTok ad pixels | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/insights`](https://pozy.ai/docs/api/queryAdInsights) | Flexible live insights query | --- # LinkedIn Ads Create and boost LinkedIn campaigns with B2B targeting, every creative format, matched audiences, bid and delivery forecasts and the public ad library. A `linkedinads` connection runs LinkedIn's Marketing API. You can create sponsored content from scratch in any of LinkedIn's formats, boost Company Page posts, target by industry, company size, seniority, job function, title and skill, build matched audiences, forecast delivery before you spend, and read firmographic breakdowns of who saw an ad. ## Before you start Three things have to be in place on LinkedIn: - An ad account. Pozy uses its numeric sponsored account id, which `GET /v1/ads/accounts` lists. - A Company Page linked to that ad account. New ads go out in the Page's name as Direct Sponsored Content, so they do not show up in the Page's own feed. - A Page role for whoever connected LinkedIn: Administrator, or Direct Sponsored Content Poster. Without one of them LinkedIn refuses with `403`. LinkedIn reviews every ad, so new campaigns sit in `DRAFT` and review before they deliver. ## Connect Call `GET /v1/connect/linkedin/ads` with the `profileId`. When the profile already has a connected LinkedIn account, the ads connection reuses its token and no consent screen appears. Otherwise the response carries an `authUrl`. The standard LinkedIn connection asks for every ads permission: reading ad accounts, managing campaigns, reporting, conversions and lead form responses. An account connected before one of these was requested must reconnect. Missing conversion access returns `403` with `linkedin_reconnect_required`; missing lead access returns `400` with `ads_connection_required`. ## How LinkedIn's levels map | Pozy | LinkedIn | Id on the created ad | | --- | --- | --- | | Campaign | Campaign group | `platformCampaignId` | | Ad set (`adSetId`) | Campaign: bidding, targeting, schedule, budget | `platformAdSetId` | | Ad | Creative | `platformAdId` | ## Create a campaign `POST /v1/ads/create` creates the Direct Sponsored Content post, then the campaign group, campaign and creative around it. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: li-ops-guide-q4" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c5a", "adAccountId": "512345678", "organizationId": "10293847", "name": "Ops leaders - guide download", "goal": "traffic", "budgetAmount": 60, "budgetType": "daily", "headline": "The 2027 field ops playbook", "body": "How 40 operations teams cut dispatch time by a third. Free guide.", "imageUrl": "https://cdn.example.com/ops-guide-1200x627.png", "linkUrl": "https://example.com/guide", "callToAction": "DOWNLOAD", "status": "PAUSED", "targeting": { "countries": ["US", "CA"], "seniorities": ["6", "7"], "jobFunctions": ["18"], "companySizes": ["D", "E", "F"] }, "platformSpecificData": { "costType": "CPC" } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "li-ops-guide-q4", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c5a", adAccountId: "512345678", organizationId: "10293847", name: "Ops leaders - guide download", goal: "traffic", budgetAmount: 60, budgetType: "daily", headline: "The 2027 field ops playbook", body: "How 40 operations teams cut dispatch time by a third. Free guide.", imageUrl: "https://cdn.example.com/ops-guide-1200x627.png", linkUrl: "https://example.com/guide", callToAction: "DOWNLOAD", status: "PAUSED", targeting: { countries: ["US", "CA"], seniorities: ["6", "7"], jobFunctions: ["18"], companySizes: ["D", "E", "F"] }, platformSpecificData: { costType: "CPC" }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.platformAdSetId); // the LinkedIn campaign ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "li-ops-guide-q4", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c5a", "adAccountId": "512345678", "organizationId": "10293847", "name": "Ops leaders - guide download", "goal": "traffic", "budgetAmount": 60, "budgetType": "daily", "headline": "The 2027 field ops playbook", "body": "How 40 operations teams cut dispatch time by a third. Free guide.", "imageUrl": "https://cdn.example.com/ops-guide-1200x627.png", "linkUrl": "https://example.com/guide", "callToAction": "DOWNLOAD", "status": "PAUSED", "targeting": {"countries": ["US", "CA"], "seniorities": ["6", "7"], "jobFunctions": ["18"], "companySizes": ["D", "E", "F"]}, "platformSpecificData": {"costType": "CPC"}, }, timeout=60, ) resp.raise_for_status() print(resp.json()["ad"]["_id"]) ``` Field rules: - `headline` is required, plus exactly one of `imageUrl` or `video: { url }`. `body` is the intro text. - `organizationId` names the authoring Page, as a number or `urn:li:organization:N`. It is optional when the Page is connected to Pozy or owns the ad account. - `goal: "traffic"` needs `linkUrl`. Buttons accept `LEARN_MORE`, `SIGN_UP`, `DOWNLOAD`, `SUBSCRIBE`, `REGISTER`, `JOIN`, `ATTEND`, `REQUEST_DEMO`, `VIEW_QUOTE`, `APPLY`, `SEE_MORE`, `SHOP_NOW` and `BUY_NOW`. - A video upload blocks while LinkedIn transcodes it, usually 10 to 30 seconds. - `status: "PAUSED"` pauses only the top level the call creates: a new campaign group, a new campaign (kept in draft) or the new creative. ### Goals | `goal` | On create | On boost | Notes | | --- | --- | --- | --- | | `engagement`, `traffic`, `awareness` | Yes | Yes | | | `lead_generation` | Yes | Yes | Needs `leadGenFormId` ([Lead forms](https://pozy.ai/docs/ads/lead-forms)) | | `video_views` | Yes | Yes | Needs a video | | `job_applicants` | Yes | – | Needs the `jobs` format | | `conversions` | – | Yes | Boost only | ### Reuse an existing campaign or group Send `adSetId` (a LinkedIn campaign id) to add a new creative under that campaign: bidding, targeting, schedule and budget are inherited, so sending any of them returns `400`. Send `existingCampaignId` (a campaign group id) to create a new campaign in that group with its own settings. The two are mutually exclusive. If a create fails, Pozy cleans up only what it created and never touches the parent. ## Creative formats One key inside `platformSpecificData` selects the format. Formats exclude each other and the top-level `imageUrl` and `video`. | Format | Key | Goals | Notes | | --- | --- | --- | --- | | Single image or link (article, newsletter) | none | engagement, traffic, awareness, lead_generation | `imageUrl` + `linkUrl` | | Single video | none (`video`) | all base goals plus video_views | MP4 H.264/AAC, 3 s to 30 min, 75 KB to 500 MB | | Carousel | `carousel` | base goals | 2 to 10 cards, each with a landing URL (or the ad's `linkUrl`) | | Document | `document` | base goals | PDF, PPT or DOC up to 100 MB, swipeable in the feed | | Event | `event` | base goals | Promotes an existing LinkedIn Event by `urn` | | Text ad | `textAd` | base goals | Right rail. Headline 25 characters, description 75, optional 100 x 100 logo | | Spotlight | `spotlight` | traffic, awareness | Personalized with the viewer's photo | | Follower | `follower` | engagement, awareness | Drives Page follows | | Jobs | `jobs` | job_applicants | Shows the Page's live job postings | | Conversation | `conversation` | base goals | A message tree in LinkedIn messaging: up to 25 messages, 5 buttons each, 500 characters per message | | Thought leader | `thoughtLeader` | base goals | Sponsors an existing Company Page post (`urn:li:share:N` or `urn:li:ugcPost:N`) under a campaign you control | Conversation ads need a sender approved for the ad account in Campaign Manager, or they fail with `SINMAIL_SENDER_NOT_APPROVED`. Thought leader and boost only work on posts authored by the Company Page; LinkedIn rejects personal-profile posts. ## Bidding and budgets Bidding fields go in `platformSpecificData` on create and boost: | Field | Values | | --- | --- | | `costType` | `CPM` (default), `CPC`, `CPV`. Required with `unitCost`. | | `unitCost` | Manual bid in whole currency units. Omit for automated bidding. | | `optimizationTargetType` | LinkedIn's value, such as `MAX_CLICK` or `TARGET_COST_PER_CLICK` | | `creativeSelection` | `OPTIMIZED` (default) or `ROUND_ROBIN` | | `audienceExpansionEnabled`, `offsiteDeliveryEnabled`, `connectedTelevisionOnly` | Switches for where and to whom the ads deliver | LinkedIn requires at least $10 a day per campaign, and $100 lifetime on an inactive campaign. Bidding cannot be changed after create through Pozy. ### Forecast before you spend | Call | Answers | | --- | --- | | `POST /v1/ads/targeting/bid-pricing` | The suggested bid and the bid and daily budget LinkedIn accepts for a targeting `spec`. `dailyBudgetLimits.min` is the real minimum for that audience. | | `POST /v1/ads/targeting/supply-forecast` | Projected impressions, clicks and spend over a future window (`timeRangeStart`, `timeRangeEnd` in Unix milliseconds) for a `dailyBudget` or `totalBudget` | | `POST /v1/ads/targeting/reach-estimate` | Audience size for the spec | Send `objectiveType` (for example `WEBSITE_VISIT`) on the forecast: LinkedIn rejects it without one. Read each series by its `metricType`; the `CUSTOM` granularity is the total over your window. On accounts that are not LinkedIn, the planning calls return `{ "available": false }` with `200`, so a multi-network UI can call them unconditionally. ## Targeting LinkedIn targets by location and professional facets. Send them in a nested `targeting` object: | Field | Values | | --- | --- | | `countries`, `regions` | ISO codes, or `urn:li:geo:*` keys from the search | | `industries`, `seniorities`, `jobFunctions`, `jobTitles`, `skills` | Id fragments or full URNs from the search | | `companySizes` | `A` (1), `B` (2 to 10), `C` (11 to 50), `D` (51 to 200), `E` (201 to 500), `F` (501 to 1,000), `G` (1,001 to 5,000), `H` (5,001 to 10,000), `I` (10,001+) | | `interests`, `behaviors` | `[{ "id": "urn:..." }]` from the search | | `audienceInclude`, `audienceExclude` | Matched audience segment ids | | `ageMin`, `ageMax`, `gender`, `languages` | Rounded to LinkedIn's age buckets (18-24, 25-34, 35-54, 55+) | `jobTitles` cannot be combined with `seniorities` or `jobFunctions`. Cities, ZIP codes, radius and income are not available on LinkedIn. `PUT /v1/ads/{adId}` with `targeting` updates the live campaign facet by facet. See [Targeting](https://pozy.ai/docs/ads/targeting#linkedin) for the lookup dimensions. ## Matched audiences `POST /v1/ads/audiences` builds four LinkedIn audience types: contact lists (`customer_list`), company lists (`company_list`, up to 300,000 rows), engagement retargeting (`engagement`) and website retargeting (`website_retargeting`, fed by the Insight Tag). Contact list uploads replace the list rather than adding to it. Details and limits are on [Audiences](https://pozy.ai/docs/ads/audiences#audience-types). ## Lead gen forms and conversions LinkedIn lead forms are created, listed and archived through the lead form endpoints, and responses are kept for 90 days. LinkedIn sends no lead webhook, so poll for new leads. Server-side conversions use conversion rules. Both are covered on [Lead forms](https://pozy.ai/docs/ads/lead-forms) and [Conversions](https://pozy.ai/docs/ads/conversions). ## Manage running ads | Job | Call | Notes | | --- | --- | --- | | Pause or resume | `PUT /v1/ads/campaigns/{campaignId}/status` (group), `/ad-sets/{adSetId}/status` (campaign), `/{adId}/status` (creative) | A creative still in `DRAFT` cannot be paused before approval. | | Change the budget | `PUT /v1/ads/{adId}` with `budget` | The budget belongs to the LinkedIn campaign that holds the ad, so every creative in that campaign shares it. | | Swap media | `PUT /v1/ads/{adId}` with `creative` and a new `imageUrl` or `videoUrl` | Pozy creates a new creative and pauses the old one. Text-only edits return `400`. | | Rename | `PUT /v1/ads/ad-sets/{adSetId}` with `name` | Creatives cannot be renamed. | | Duplicate | `POST /v1/ads/campaigns/{campaignId}/duplicate` with `platform: "linkedin"` | Copies the group, its campaigns and creatives. `statusOption` defaults to `PAUSED`. | | Delete | `DELETE` on the ad, ad set or campaign | Campaigns and groups move to `PENDING_DELETION` and stay readable for reporting. | ## Reporting Spend, impressions, clicks, CPC and CPM roll up on `GET /v1/ads/tree`. Firmographic breakdowns come from `GET /v1/ads/{adId}/analytics` or `GET /v1/ads/campaigns/{campaignId}/analytics` with `breakdowns`: | Dimension | Shows | | --- | --- | | `job_title`, `job_function`, `seniority` | Who the viewers are | | `industry`, `company`, `company_size` | Where they work | | `country`, `region` | Where they are | Each row carries LinkedIn's URN in `value` and a readable `name`. Breakdowns cover the whole range (LinkedIn does not split them by day), lag 12 to 24 hours and omit segments with fewer than 3 events. Each dimension costs one extra call to LinkedIn, so ask only for the ones you show. ## Ad library `GET /v1/ads/library?platform=linkedin&accountId=...&q=...` searches LinkedIn's public archive of ads served since June 2023, by keyword (`q`), `advertiser`, `countries` and a `since` / `until` window. Pages hold at most 25 ads. EU ads also carry impression ranges and targeting disclosures. ## Limits and errors | Error | Cause | Fix | | --- | --- | --- | | `403` with LinkedIn's message | The connected person has no suitable Page role, or the Page is not linked to the ad account | Grant the role, or link the Page to the ad account, on LinkedIn. | | `400` with both `adSetId` and `existingCampaignId` | They exclude each other | Send one. | | `400` on `platformSpecificData` | An unknown key, or two format keys | Send one format key and the bidding keys above. | | `400` `mutually_exclusive_fields` | `jobTitles` with `seniorities` or `jobFunctions` | Keep one side. | | `422` with `INVALID_URN_TYPE` | Boosting a personal-profile post | Boost a post the Company Page authored. | | `422` mentioning sponsorship authorization | The Page post is not approved for sponsorship | The author approves it in LinkedIn's sponsorship settings. | | `501` on `name` for an ad | Creatives cannot be renamed | Rename the campaign instead. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`POST /v1/ads/campaigns/{campaignId}/duplicate`](https://pozy.ai/docs/api/duplicateAdCampaign) | Duplicate a campaign | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`DELETE /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/deleteAdSet) | Delete an ad set | | [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign | | [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options | | [`POST /v1/ads/targeting/reach-estimate`](https://pozy.ai/docs/api/estimateAdReach) | Estimate audience reach | | [`POST /v1/ads/targeting/bid-pricing`](https://pozy.ai/docs/api/getLinkedInBidPricing) | Suggested bid and budget bounds | | [`POST /v1/ads/targeting/supply-forecast`](https://pozy.ai/docs/api/getLinkedInSupplyForecast) | Forecast ad delivery | | [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience | | [`POST /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/addUsersToAdAudience) | Add users to audience | | [`POST /v1/ads/audiences/{audienceId}/companies`](https://pozy.ai/docs/api/replaceAdAudienceCompanies) | Replace audience companies | | [`GET /v1/ads/audiences`](https://pozy.ai/docs/api/listAdAudiences) | List custom audiences | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/campaigns/{campaignId}/analytics`](https://pozy.ai/docs/api/getCampaignAnalytics) | Get campaign analytics | | [`GET /v1/ads/library`](https://pozy.ai/docs/api/searchAdLibrary) | Search the public Ad Library | --- # Pinterest Ads Run Promoted Pins on a pinterestads account, from a new Pin or one you already published, with targeting, customer lists, the Pinterest tag and conversions. A Pinterest ad is a Promoted Pin. You can create a fresh Pin and promote it in one request, or put budget behind a Pin that is already live and keep its saves and comments. Both run on a `pinterestads` connection, and the ad endpoints refer to the network as `pinterest`. | | | | --- | --- | | Connection platform | `pinterestads` | | `platform` on ad endpoints | `pinterest` | | Structure | Campaign, ad group, Promoted Pin, all created by one call | | Goals | `engagement`, `traffic`, `awareness`, `video_views` | | Minimum budget | 5 in the ad account currency, daily or lifetime | | Changes after launch | Status and budget | | Audiences | Customer lists, matched on email | | Measurement | Pinterest tag and Conversions API | ## Connect Pinterest uses one token for posting and for ads. If the profile already has a Pinterest account connected, the ads connection reuses its token and no consent screen appears. ```bash cURL curl "https://api.pozy.ai/v1/connect/pinterest/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/pinterest/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.alreadyConnected ? data.accountId : data.authUrl); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/pinterest/ads", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66b2e19d8c3f5a7e9d0b1c2a"}, ) print(resp.json()) ``` ```json { "alreadyConnected": true, "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platform": "pinterest", "username": "@acmehome", "displayName": "Acme Home" } ``` `accountId` is the `pinterestads` connection. Every example below uses it. - **No Pinterest account in the profile yet.** The response carries `authUrl` and `state`. Send the person to `authUrl`; the consent screen asks for `ads:read` and `ads:write` along with the posting scopes. - **Connected before ads existed.** A Pinterest account connected without the ads scopes goes through consent once more. - **Token can no longer reach ad accounts.** Calls return `400` asking for a reconnect. Reconnect the Pinterest posting account and the ads connection picks up the new token. Pinterest does not let Pozy discover ad accounts at connect time, so `adAccountId` and `adAccountIds` on the connect call have no effect. List the ad accounts with `GET /v1/ads/accounts?accountId=...` and pass `adAccountId` on each request instead. ## Create a Promoted Pin `POST /v1/ads/create` creates the Pin, then the campaign, ad group and ad around it. Because the Pin is built from the request, send `headline`, `body`, `imageUrl` and `linkUrl` together. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: spring-decor-2027-01" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "549123456789", "name": "Spring home decor", "goal": "traffic", "budgetAmount": 30, "budgetType": "daily", "headline": "Freshen up your space", "body": "New spring pieces, in stock now.", "imageUrl": "https://cdn.example.com/spring-decor.jpg", "linkUrl": "https://example.com/spring", "boardId": "1029384756473829101", "countries": ["US", "CA"], "ageMin": 25, "ageMax": 44 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "spring-decor-2027-01", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "549123456789", name: "Spring home decor", goal: "traffic", budgetAmount: 30, budgetType: "daily", headline: "Freshen up your space", body: "New spring pieces, in stock now.", imageUrl: "https://cdn.example.com/spring-decor.jpg", linkUrl: "https://example.com/spring", boardId: "1029384756473829101", countries: ["US", "CA"], ageMin: 25, ageMax: 44, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad._id, data.ad.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "spring-decor-2027-01", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "549123456789", "name": "Spring home decor", "goal": "traffic", "budgetAmount": 30, "budgetType": "daily", "headline": "Freshen up your space", "body": "New spring pieces, in stock now.", "imageUrl": "https://cdn.example.com/spring-decor.jpg", "linkUrl": "https://example.com/spring", "boardId": "1029384756473829101", "countries": ["US", "CA"], "ageMin": 25, "ageMax": 44, }, ) print(resp.json()["ad"]["status"]) ``` ```json { "ad": { "_id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Spring home decor", "platform": "pinterest", "status": "pending_review", "adType": "standalone", "goal": "traffic", "budget": { "amount": 30, "type": "daily" }, "platformCampaignId": "626746010001", "platformAdSetId": "2680069880002", "platformAdId": "687195120340" }, "message": "Ad created" } ``` `platformAdSetId` is the Pinterest ad group. The ad starts in `pending_review` and moves to `active` once Pinterest approves it. Subscribe to `ad.review.approved` and `ad.review.rejected` instead of polling. > **Pick the board:** > > `boardId` decides which board the new Pin lands on. Without it, Pinterest Ads puts the Pin on a separate ads board created the first time you launch. Pass a board you own so the Pin sits where your audience expects it. ### Copy and creative | Field | Rule | | --- | --- | | `headline` | Required. Up to 100 characters. | | `body` | Up to 500 characters. Send it with the headline. | | `imageUrl` | Required. A public URL that returns the image bytes. Pozy fetches it. | | `linkUrl` | Required. Where a tap on the Pin goes. | | `boardId` | Optional. The board for the new Pin. | To run a video Pin, publish the video to Pinterest first (see [Pinterest](https://pozy.ai/docs/platforms/pinterest)) and promote it with a boost. ### Budget and schedule - `budgetAmount` is in whole units of the ad account currency, so `30` is $30.00 on a USD account. The minimum is 5. - `budgetType` is `daily` or `lifetime`. - `startDate` and `endDate` set the run window. Without them the Promoted Pin runs until you pause it. - Some ad accounts require Campaign Budget Optimization. Those need a `lifetime` budget with an `endDate`, and a create without one returns `400` naming `endDate`. ## Promote a Pin you already published `POST /v1/ads/boost` keeps the Pin's existing engagement. Pass the Pozy `postId` of a post published to Pinterest, or the Pinterest Pin id as `platformPostId`. ```bash curl -X POST https://api.pozy.ai/v1/ads/boost \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: boost-65f1c0a9" \ -d '{ "postId": "65f1c0a9e2b5af0012ab34cd", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "549123456789", "name": "Boost decor Pin", "goal": "traffic", "budget": { "amount": 30, "type": "daily" }, "schedule": { "startDate": "2027-01-04T09:00:00Z", "endDate": "2027-02-04T23:59:00Z" } }' ``` The same four goals and the 5 minimum apply. A boost is the route for video Pins. More on boosting across networks is in [Boost posts](https://pozy.ai/docs/ads/boost-posts). ## Targeting Targeting is set on the create and cannot change afterwards. | Field | What it targets | | --- | --- | | `countries` | ISO country codes | | `regions`, `metros`, `zips` | Sub-country locations. Send `regions` or `zips`, never both (`400 mutually_exclusive_fields`). | | `ageMin`, `ageMax`, `gender` | Demographics | | `languages` | ISO 639-1 codes. A code Pinterest cannot target returns `400`. | | `interests` | `{ "id": "..." }` values from `GET /v1/ads/interests` | | `audienceInclude`, `audienceExclude` | Pinterest customer list ids | `incomeTier`, `cities`, `customLocations` and `behaviors` are not available on Pinterest and return `400`. The cross-network field list is in [Targeting](https://pozy.ai/docs/ads/targeting). ### Customer lists Create the audience with `type: "customer_list"`, then upload members. Pinterest matches on email only, so a member with only a `phone` is not matched. ```bash curl -X POST https://api.pozy.ai/v1/ads/audiences \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "549123456789", "type": "customer_list", "name": "Repeat buyers" }' curl -X POST https://api.pozy.ai/v1/ads/audiences/66e5b2c3d4f5a6b7c8d9e0f1/users \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` - Send plain emails. Pozy hashes each one with SHA-256 before it leaves. - One upload takes up to 10,000 users. - Pinterest needs members to create a list, so the audience stays `pending` with no Pinterest id until the first upload. That upload creates it on Pinterest. ## Manage a running campaign After launch you can change delivery and money, nothing else. | Change | Call | | --- | --- | | Pause or resume the campaign | `PUT /v1/ads/campaigns/{campaignId}/status` with `{ "status": "paused", "platform": "pinterest" }` | | Pause or resume an ad group or a Promoted Pin | `PUT /v1/ads/ad-sets/{adSetId}/status` or `PUT /v1/ads/{adId}/status` | | Change the budget | `PUT /v1/ads/{adId}` with `budget: { amount, type }`. It is written to the Pin's ad group. | | Rename the ad group | `PUT /v1/ads/ad-sets/{adSetId}` with `name` | | Stop for good | `DELETE /v1/ads/{adId}`, `/v1/ads/ad-sets/{adSetId}` or `/v1/ads/campaigns/{campaignId}` | Each level has its own switch. Resuming a campaign only restarts the ad groups and Pins whose own switch is on. Pinterest does not report a campaign's switch back, so `platformCampaignStatus` is always `null` on Pinterest. Pinterest has no hard delete. Deleting archives the object and marks Pozy's copy `cancelled`, which keeps its spend in your reports. If you delete a campaign that still has ad groups Pozy does not know about, the call returns `409 invalid_resource_state` and nothing changes; delete the ad groups first or sync the ad account and try again. Sending `targeting` or `creative` to `PUT /v1/ads/{adId}` returns `501 feature_not_available`. Create a new ad instead. Campaign duplication is not available on Pinterest either. ## Measure results `GET /v1/ads/{adId}/analytics` returns spend, impressions, clicks, CTR, CPC and CPM for a date range, as a `summary` and a `daily` series. `GET /v1/ads/tree` rolls the same numbers up per campaign and ad group. ```bash curl "https://api.pozy.ai/v1/ads/66f0a1b2c3d4e5f6a7b8c9d0/analytics?fromDate=2027-01-04&toDate=2027-01-11" \ -H "Authorization: Bearer $POZY_API_KEY" ``` - `reach` reads `0` on Pinterest because it is not synced. Use impressions. - On connect, Pozy imports the last 90 days of metrics. Days before that have no data. - Demographic `breakdowns` are not available for Pinterest ads. [Reporting](https://pozy.ai/docs/ads/reporting) covers the cross-network metrics. ### Pinterest tag and conversions Pinterest measures site activity with the Pinterest tag, managed through the tracking tags endpoints on the `pinterestads` connection. A few Pinterest rules to plan around: - Create a tag with `POST /v1/accounts/{accountId}/tracking-tags`. Enhanced match (`automaticMatchingFields`) can only be set at creation, because Pinterest cannot edit a tag later. - Creating a tag is not idempotent and Pinterest has no tag delete. After a timeout, list the tags before you try again. - Custom conversion events belong to the ad account: up to 15, named with 1 to 100 letters, digits, `_` or `-`. - Install the tag on a connected Shopify, WordPress, Wix or Webflow site with one call. See [Pixels](https://pozy.ai/docs/ads/pixels). Server-side events go to `POST /v1/ads/conversions` with the numeric ad account id as `destinationId`, because Pinterest attributes to the ad account rather than to a tag. The connected Pinterest user needs a Business Access role on that ad account. Details, hashing and dedupe are in [Conversions](https://pozy.ai/docs/ads/conversions). ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `400 missing_required_field` | `headline`, `body`, `imageUrl`, `linkUrl` or `goal` missing | Send all of them. Pinterest builds the Pin from them. | | `400 invalid_field_value` | A goal outside the four, a headline over 100 or body over 500 characters | Use a supported goal and trim the copy. | | `400 mutually_exclusive_fields` | `regions` and `zips` together | Send one of them. | | `400` naming `endDate` | The ad account requires Campaign Budget Optimization | Send a `lifetime` budget with `endDate`. | | `403` | The Pinterest user lacks Business Access on the ad account (tags, conversions) | Grant Admin, Analyst, Audience or Campaign access in Pinterest. | | `403 ads_allowance_exceeded` | No payment method and the workspace reached its free live-ads allowance | Add a payment method. | | `501 feature_not_available` | Targeting or creative edits after launch | Create a new ad. | | `502 platform_api_error` | Pinterest rejected the request | Read `platformError` in the body for Pinterest's own message. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`DELETE /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/deleteAdSet) | Delete an ad set | | [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/interests`](https://pozy.ai/docs/api/searchAdInterests) | Search targeting interests | | [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience | | [`POST /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/addUsersToAdAudience) | Add users to audience | | [`GET /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/listTrackingTags) | List tracking tags | | [`POST /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/createTrackingTag) | Create a tracking tag | | [`POST /v1/ads/conversions`](https://pozy.ai/docs/api/sendConversions) | Send conversion events | | [`GET /v1/ads/conversions/quality`](https://pozy.ai/docs/api/getConversionsQuality) | Get Event Match Quality | ## Webhook events | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`ad.status_changed`](https://pozy.ai/docs/webhooks/events#ad-status-changed) | Ad status changed. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | --- # X Ads Run promoted posts on X from an xads connection, with website cards, Tailored Audiences, bid control and the X Pixel. X ads are promoted posts. Pozy can write a new post that only shows as an ad, or promote a post you already published so it keeps its replies and likes. Both run on an `xads` connection, and the ad endpoints refer to X as `twitter`. | | | | --- | --- | | Connection platform | `xads` (created from a connected X account) | | `platform` on ad endpoints | `twitter` | | Structure | Campaign, line item, promoted post, all created by one call | | Goals | `engagement`, `traffic`, `awareness`, `video_views`, `app_promotion` | | Ad text | Up to 280 characters | | Changes after launch | Status, budget and bid | | Audiences | Tailored Audiences (customer lists, matched on email) | | Measurement | X Pixel. Server-side conversions are not available for X. | Pozy signs every X Ads request for you. You authenticate with your Pozy key like on any other network. ## Connect X Ads uses its own authorization, separate from posting, and it always starts from an X account that is already connected to the profile. There is no ads-only X connection. > **Payment method:** > > X bills every API call, so connecting the first X account to a workspace needs a payment method on file. Without one the posting connection returns `402 payment_required`, and there is nothing to build the ads connection from. Call the connect endpoint with the profile and the X posting account: ```bash cURL curl "https://api.pozy.ai/v1/connect/twitter/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&accountId=66b2e19d8c3f5a7e9d0b1c2b&redirect_url=https://app.example.com/ads/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/twitter/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&accountId=66b2e19d8c3f5a7e9d0b1c2b&redirect_url=https:%2F%2Fapp.example.com%2Fads%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.authUrl ?? data.accountId); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/twitter/ads", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "66b2e19d8c3f5a7e9d0b1c2a", "accountId": "66b2e19d8c3f5a7e9d0b1c2b", "redirect_url": "https://app.example.com/ads/connected", }, ) print(resp.json()) ``` The first call always returns an `authUrl`, because X Ads keeps its own token. Send the person there. After consent they land on your `redirect_url`, and calling the endpoint again returns `alreadyConnected: true` with the `accountId` of the new `xads` connection. If consent fails, the redirect carries `platform=xads` and an `error` such as `x_ads_denied`, `x_ads_auth_failed` or `x_ads_token_failed`. `adAccountId` (or `adAccountIds`, repeated or comma separated) on the connect call limits ad sync to those X ad accounts. Leaving both out keeps the scope you stored before. Narrowing the scope removes synced ads from the accounts you drop. X ad account ids are short base36 strings such as `18ce54d4x5t`. List them with `GET /v1/ads/accounts?accountId=...`. > **Either account id works:** > > On ad calls, `accountId` can be the X posting account or the `xads` connection. Pozy resolves one from the other. If the profile has no X Ads connection, the call returns `422 ads_connection_required` with `param: "accountId"`. ## Create a campaign `POST /v1/ads/create` publishes a promoted-only post from the ad account's promotable user, then builds the campaign and line item around it. The post never shows on the organic timeline. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: q2-platform-launch" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "18ce54d4x5t", "name": "Q2 platform launch", "goal": "traffic", "budgetAmount": 500, "budgetType": "lifetime", "startDate": "2027-01-04T00:00:00+01:00", "endDate": "2027-01-31T23:59:59+01:00", "body": "Ship faster with a platform your whole team can use.", "imageUrl": "https://example.com/images/platform-card.png", "headline": "Platform engineering, done right", "linkUrl": "https://example.com/platform", "countries": ["US"], "languages": ["en"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "q2-platform-launch", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "18ce54d4x5t", name: "Q2 platform launch", goal: "traffic", budgetAmount: 500, budgetType: "lifetime", startDate: "2027-01-04T00:00:00+01:00", endDate: "2027-01-31T23:59:59+01:00", body: "Ship faster with a platform your whole team can use.", imageUrl: "https://example.com/images/platform-card.png", headline: "Platform engineering, done right", linkUrl: "https://example.com/platform", countries: ["US"], languages: ["en"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.platformAdSetId); // the X line item ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "q2-platform-launch", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "18ce54d4x5t", "name": "Q2 platform launch", "goal": "traffic", "budgetAmount": 500, "budgetType": "lifetime", "startDate": "2027-01-04T00:00:00+01:00", "endDate": "2027-01-31T23:59:59+01:00", "body": "Ship faster with a platform your whole team can use.", "imageUrl": "https://example.com/images/platform-card.png", "headline": "Platform engineering, done right", "linkUrl": "https://example.com/platform", "countries": ["US"], "languages": ["en"], }, ) print(resp.json()["ad"]["status"]) ``` ```json { "ad": { "_id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Q2 platform launch", "platform": "twitter", "status": "pending_review", "adType": "standalone", "goal": "traffic", "budget": { "amount": 500, "type": "lifetime" }, "platformCampaignId": "f4x8j", "platformAdSetId": "9r2mv", "platformAdId": "5gzhb" }, "message": "Ad created" } ``` ### Text and website cards The creative is the post text plus, optionally, a website card. | You send | You get | | --- | --- | | `body` only | A text post. Required on every create, up to 280 characters. | | `body` + `linkUrl` | The link is added to the text. X shortens it, and it uses about 24 of the 280 characters. | | `body` + `imageUrl` + `headline` + `linkUrl` | A website card: the image, the headline as its title (up to 70 characters) and the link under the post. The text keeps all 280 characters. | `imageUrl` or `headline` without the other two returns `400`. The image must be a public URL; Pozy downloads it and uploads it to X. `description`, `video` and `callToAction` have no place on an X ad and return `400`. To run a video ad, publish the video as a normal X post first and promote it. ### Budget and schedule - `budgetAmount` is whole units of the ad account currency. `budgetType` is `daily` or `lifetime`, and it lands on the line item. - A `lifetime` budget needs `endDate`. `startDate` defaults to now. - Send `startDate` and `endDate` with an offset to pin exact times. A value without an offset, including a plain date, is read in the ad account's time zone, and a date-only `endDate` runs to 23:59:59 that day. ## Promote an existing post `POST /v1/ads/boost` promotes a post that is already on X, with its media, video included. Pass the Pozy `postId` of a post published to X, or the X post id as `platformPostId`. ```bash curl -X POST https://api.pozy.ai/v1/ads/boost \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: boost-launch-post" \ -d '{ "postId": "65f1c0a9e2b5af0012ab34cd", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "18ce54d4x5t", "name": "Boost launch post", "goal": "engagement", "budget": { "amount": 50, "type": "daily" }, "schedule": { "startDate": "2027-01-04", "endDate": "2027-01-11" } }' ``` The goals are the same five as on a create. See [Boost posts](https://pozy.ai/docs/ads/boost-posts) for how boosting works across networks. ## Targeting Targeting goes on the create and becomes the line item's targeting criteria. | Field | Values | | --- | --- | | `countries` | ISO country codes | | `regions`, `metros`, `cities`, `zips` | `{ "key": "..." }` from targeting search with `dimension=geo` | | `interests` | `{ "id": "..." }` from targeting search with `dimension=interest` | | `audienceInclude`, `audienceExclude` | X ids of [Tailored Audiences](#tailored-audiences) | | `ageMin`, `ageMax` | Rounded out to an X age range (see below) | | `gender` | `male` or `female`. `all` sends no gender filter. | | `languages` | Codes such as `en`. A region suffix (`en_GB`) is dropped. | X targets one age range from a fixed list. Ranges start at 13, 18, 21, 25, 35 or 50 and end at 24, 34, 49 or 54, or stay open. Pozy picks the narrowest range that covers your numbers, so `ageMin: 20, ageMax: 40` becomes 18 to 49. `incomeTier`, `customLocations` and `behaviors` return `400`. Keyword targeting and follower look-alikes are not available. ### Find geo and interest values Geo keys and interest ids are X's own. A key from another network does not work. ```bash curl "https://api.pozy.ai/v1/ads/targeting/search?accountId=66b2e19d8c3f5a7e9d0b1c2d&q=Austin&dimension=geo&geoType=city&countryCode=US" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "results": [{ "id": "3b77caf94bfc81fe", "name": "Austin", "type": "city", "countryCode": "US" }] } ``` Send a geo result's `id` as `key`. `geoType` is `country`, `region`, `metro_area`, `city` or `zip`. Interest results carry their parent categories in `path`. More in [Targeting](https://pozy.ai/docs/ads/targeting). ### Tailored Audiences A Tailored Audience is a customer list. Create it with `type: "customer_list"` (the only type X supports), then upload emails. ```bash curl -X POST https://api.pozy.ai/v1/ads/audiences/66e5b2c3d4f5a6b7c8d9e0f1/users \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "users": [{ "email": "jane@example.com" }, { "email": "sam@example.com" }] }' ``` - X matches on email and ignores `phone`. Pozy hashes every value with SHA-256 before sending. - One upload takes up to 10,000 users. - X lets you target a list once it matches at least 100 recently active users. Until then its size reads `0`. ## Manage a running campaign After launch only status, budget and bid change. Targeting and creative stay as created; sending them to `PUT /v1/ads/{adId}` returns `501 feature_not_available`. ### Pause and resume X has no on/off switch per promoted post. `PUT /v1/ads/{adId}/status` sets the switch of the ad's line item, which every promoted post in that line item shares, and `configuredStatus` comes back `null`. | Level | Call | | --- | --- | | Campaign | `PUT /v1/ads/campaigns/{campaignId}/status` with `{ "status": "paused", "platform": "twitter" }` | | Line item | `PUT /v1/ads/ad-sets/{adSetId}/status` | | Ad | `PUT /v1/ads/{adId}/status` (writes the line item) | Pausing a campaign stops everything in it. Resuming it restarts a line item only if that line item's own switch is on. ### Budget and bid - `budget: { amount, type }` on `PUT /v1/ads/{adId}`: Pozy finds where X holds the budget (line item or campaign) and writes it there. - `PUT /v1/ads/ad-sets/{adSetId}` with `platform: "twitter"` changes a line item's budget, name or bid. - `PUT /v1/ads/campaigns/{campaignId}` changes a campaign budget or renames it. If X holds the budget on the line item, a campaign budget returns `409`. | `bidStrategy` | X bidding | Needs | | --- | --- | --- | | `LOWEST_COST_WITHOUT_CAP` | Automatic | | | `LOWEST_COST_WITH_BID_CAP` | Maximum bid | `bidAmount` | | `COST_CAP` | Target cost | `bidAmount` | X decides which strategy an objective allows: target cost works on reach, followers and website clicks only, and maximum bid not on reach or followers. Minimum-ROAS bidding is not available on X, and any other strategy returns `422`. ### Delete `DELETE /v1/ads/{adId}` removes the promoted post on X and marks Pozy's copy `cancelled`, keeping its history. Its line item and campaign are removed too, but only when Pozy created them and nothing else is left in them. `DELETE /v1/ads/campaigns/{campaignId}` removes a whole campaign. Duplicating an X campaign is not available (`501`). ## Measure results `GET /v1/ads/{adId}/analytics` returns spend, impressions, clicks, CTR, CPC, CPM and engagement for a range. `GET /v1/ads/tree` rolls them up per campaign and line item. - `reach` reads `0` because it is not synced from X. - On connect, Pozy imports the last 90 days of metrics. - Demographic `breakdowns` are not available on X. - Conversions show up per ad and campaign as web conversion metrics. X does not report counts per pixel or per event. ### X Pixel The X Pixel is the ad account's website tag, managed through the tracking tags endpoints on the `xads` connection. X allows one per ad account. - `POST /v1/accounts/{accountId}/tracking-tags` with the X ad account id as `adAccountId` creates it with first-party cookies on. A second create returns `409`, and X has no pixel delete. - `tagId` is the X ad account id. `siteTagId` is the pixel id the site loads. - `PATCH` changes only `firstPartyCookieStatus` (`first_party_cookie_enabled` or `first_party_cookie_disabled`). - Conversion events take a `type` such as `PURCHASE`, `SIGN_UP` or `CUSTOM`, a `clickWindowDays` of 1, 7, 14, 30, 60 or 90 and a `viewWindowDays` of 0, 1, 7, 14, 30, 60 or 90 (never longer than the click window). New events default to 30 and 1 days. Creating one is not idempotent; deleting one is permanent. - Events X creates with the pixel are hidden from the list and cannot be changed. Install it on a connected store or site with one call. See [Pixels](https://pozy.ai/docs/ads/pixels). ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `422 ads_connection_required` | The profile has no `xads` connection | Run the connect flow with the X posting account id. | | `400 missing_required_field` | No `body`, or a website card missing `imageUrl`, `headline` or `linkUrl` | Send the text, and all three card fields or none. | | `400 invalid_field_value` | A goal outside the five, text over 280, a headline over 70, or `description`, `video`, `callToAction` | Fix the field the message names. | | `409` | A campaign budget when X holds it on the line item | Change the budget through the ad or line item. | | `403 ads_allowance_exceeded` | No payment method and the free live-ads allowance is used up | Add a payment method. | | `501 feature_not_available` | Targeting or creative edits, or a campaign duplicate | Create a new ad or campaign. | | `502 platform_api_error` | X rejected the request | Read `platformError` in the body. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set | | [`PUT /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/updateAdCampaign) | Update a campaign | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options | | [`POST /v1/ads/targeting/reach-estimate`](https://pozy.ai/docs/api/estimateAdReach) | Estimate audience reach | | [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience | | [`POST /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/addUsersToAdAudience) | Add users to audience | | [`GET /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/listTrackingTags) | List tracking tags | | [`POST /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/createTrackingTag) | Create a tracking tag | | [`PATCH /v1/accounts/{accountId}/tracking-tags/{tagId}`](https://pozy.ai/docs/api/updateTrackingTag) | Update a tracking tag | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/events`](https://pozy.ai/docs/api/createTrackingTagEvent) | Create a conversion event | ## Webhook events | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | | [`ad_account.status_changed`](https://pozy.ai/docs/webhooks/events#ad-account-status-changed) | Ad account status changed. | --- # Reddit Ads Run image, video, carousel and text ads on Reddit, promote existing posts, target subreddits and keywords, and send conversions to a Reddit pixel. Reddit Ads works through its own connection, `redditads`, with its own authorization and its own `accountId`. It does not depend on an organic Reddit account, which matters because organic publishing to Reddit is paused (see [Reddit](https://pozy.ai/docs/platforms/reddit)). Campaigns, targeting, reporting and the Conversions API all work. | | | | --- | --- | | Connection platform | `redditads` | | `platform` on ad endpoints | `reddit` | | Structure | Campaign, ad group, ad, all created by one call | | Goals | `traffic`, `awareness`, `conversions`, `video_views`, `lead_generation` | | Formats | Image, video, text, a carousel of 2 to 10 cards, or a post that already exists | | Headline | Required, up to 300 characters | | Changes after launch | Status, name, budget, bid and targeting | | Audiences | Target audiences built in Reddit Ads Manager, by id | | Measurement | Reddit pixel (created in Reddit) and the Conversions API | ## Before you start Connect with a Reddit login that has a role on one or more ad accounts in Reddit Ads Manager. Set up two things there first. Reddit insists on both, and its API offers no way to make them: - **A pixel.** Each ad group carries one, even when the goal has nothing to do with conversions. If the ad account has none, creating an ad fails with `422 feature_not_available`. Add one in Events Manager inside Reddit Ads Manager. - **A profile.** On Reddit, every ad is really a post by a profile that belongs to the ad account. Pozy uses the account's first profile for new ads. ## Connect Call the connect endpoint with the profile. There is no posting account involved, so there is no `accountId` on this call. ```bash cURL curl "https://api.pozy.ai/v1/connect/redditads/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&redirect_url=https://app.example.com/ads/connected" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/redditads/ads?profileId=66b2e19d8c3f5a7e9d0b1c2a&redirect_url=https:%2F%2Fapp.example.com%2Fads%2Fconnected", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.authUrl ?? data.accountId); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/connect/redditads/ads", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"profileId": "66b2e19d8c3f5a7e9d0b1c2a", "redirect_url": "https://app.example.com/ads/connected"}, ) print(resp.json()) ``` The response carries an `authUrl`. Reddit's consent screen asks for three permissions: | Permission | Used for | Required | | --- | --- | --- | | `adsread` | Reading ad accounts, campaigns, ads, pixels and metrics | Yes | | `adsedit` | Creating and changing campaigns, ad groups, ads and their posts | Yes | | `adsconversions` | Sending events through the Conversions API | No, but conversions fail without it | After consent the browser lands on your `redirect_url`. Call the connect endpoint again to read the connection: ```json { "alreadyConnected": true, "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platform": "redditads", "username": "acme_marketing", "displayName": "Reddit Ads (acme_marketing)" } ``` On failure the redirect carries `platform=redditads` and an `error`: `reddit_ads_auth_failed` (declined), `reddit_ads_scope_not_granted`, `reddit_ads_no_ad_accounts` or `reddit_ads_callback_error`. ### Choose ad accounts By default Pozy syncs all the ad accounts the Reddit login has access to. To limit that, send `adAccountId` on the connect call, or `adAccountIds` as a repeated or comma-separated list. Ids have the form `a2_gu5tcd9ah5se`; anything else is rejected with `400`. ### Reconnect without losing history When the token stops working, or the connection lacks one of the three permissions, the connect call returns an `authUrl` again instead of `alreadyConnected`. Sending the person through it reconnects the same `accountId`, so synced ads and stored ids stay as they are. Pass `force=true` to get a fresh `authUrl` even when the connection is healthy. > **Reconnect, do not disconnect:** > > Disconnecting and connecting again creates a new connection with a new `accountId`. To fix a permission, run the connect call on the existing one. ## Create an ad `POST /v1/ads/create` writes the post and builds the campaign, ad group and ad. The creative fields you send pick the format. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: planner-launch-reddit" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "a2_gu5tcd9ah5se", "name": "Planner launch", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "The planner your team will actually open", "body": "Tasks, docs and meetings in one place. Free for teams of 5.", "imageUrl": "https://cdn.example.com/planner.png", "linkUrl": "https://example.com/planner", "callToAction": "SIGN_UP", "countries": ["US", "CA"], "targeting": { "communities": ["productivity", "r/projectmanagement"], "conversationKeywords": ["task manager"] } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "planner-launch-reddit", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "a2_gu5tcd9ah5se", name: "Planner launch", goal: "traffic", budgetAmount: 50, budgetType: "daily", headline: "The planner your team will actually open", body: "Tasks, docs and meetings in one place. Free for teams of 5.", imageUrl: "https://cdn.example.com/planner.png", linkUrl: "https://example.com/planner", callToAction: "SIGN_UP", countries: ["US", "CA"], targeting: { communities: ["productivity", "r/projectmanagement"], conversationKeywords: ["task manager"], }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad._id, data.ad.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "planner-launch-reddit", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "a2_gu5tcd9ah5se", "name": "Planner launch", "goal": "traffic", "budgetAmount": 50, "budgetType": "daily", "headline": "The planner your team will actually open", "body": "Tasks, docs and meetings in one place. Free for teams of 5.", "imageUrl": "https://cdn.example.com/planner.png", "linkUrl": "https://example.com/planner", "callToAction": "SIGN_UP", "countries": ["US", "CA"], "targeting": { "communities": ["productivity", "r/projectmanagement"], "conversationKeywords": ["task manager"], }, }, ) print(resp.json()["ad"]["status"]) ``` ```json { "ad": { "_id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Planner launch", "platform": "reddit", "status": "pending_review", "adType": "standalone", "goal": "traffic", "budget": { "amount": 50, "type": "daily" }, "platformCampaignId": "2160485120262584829", "platformAdSetId": "2160485120597863293", "platformAdId": "2160485120873925373", "platformAdAccountId": "a2_gu5tcd9ah5se" }, "message": "Ad created and submitted for review" } ``` The campaign and ad group are named ` - Campaign` and ` - Ad Group` unless you send `campaignName` and `adSetName`. If Reddit rejects a later step, Pozy archives what it already created so a failed request does not leave an empty campaign behind. Send `status: "PAUSED"` to build the whole campaign switched off and check it before any money goes out. Set `campaignStatus` when the campaign should differ from its ad group and ad. ### Formats | Format | Send | | --- | --- | | Image | `headline`, `imageUrl`, `linkUrl`, optional `body` and `callToAction` | | Video | `headline`, `video.url`, a thumbnail in `video.thumbnailUrl` or `imageUrl`, `linkUrl`, and optionally `body` and `callToAction`. The video must be a URL: `video.id` is refused. | | Carousel | `headline`, between 2 and 10 `carouselCards`, and optionally `body`. A card has `imageUrl`, `callToAction`, a caption (its `headline` or `description`) and a `linkUrl`, which defaults to the ad's own. | | Text | Just `headline` and `body`. Text ads have no destination, so sending `linkUrl` gets `400`. | Behind the scenes Reddit turns the creative into a post by the ad account's profile, open to comments, and the ad promotes it. Button labels for `callToAction`: `LEARN_MORE`, `SHOP_NOW`, `SIGN_UP`, `CONTACT_US`, `DOWNLOAD`, `GET_QUOTE`, `SUBSCRIBE`, `APPLY_NOW`, `BOOK_NOW`, `BUY_TICKETS`, `DONATE_NOW`, `GET_DIRECTIONS`, `GET_SHOWTIMES`, `LISTEN_NOW`, `ORDER_NOW`, `SEE_MENU` or `INSTALL_MOBILE_APP`. Omit the field to show no button. ### Goals and the pixel | `goal` | Optimizes for | Needs | | --- | --- | --- | | `traffic` | Clicks | | | `awareness` | Impressions | | | `video_views` | 6-second views (`optimizationGoal: "VIDEO_VIEW_6S"`, the default) or 15-second views (`VIDEO_VIEW_15S`) | A video creative | | `conversions` | One pixel event, set in `promotedObject.customEventType` | A pixel that has already received that event | | `lead_generation` | The pixel's `LEAD` event, sending people to `linkUrl` | A pixel that has already received `LEAD` | Every ad group carries a pixel. Pozy uses `promotedObject.pixelId` (or `tracking.pixelId`) when you send one, and the ad account's first pixel otherwise. List pixels with `GET /v1/accounts/{accountId}/conversion-destinations`. On `conversions`, `customEventType` is one of `PURCHASE`, `LEAD`, `SIGN_UP` (or `COMPLETE_REGISTRATION`), `ADD_TO_CART`, or `PAGE_VISIT` (or `VIEW_CONTENT`, `CONTENT_VIEW`): ```json { "goal": "conversions", "promotedObject": { "pixelId": "a2_gu5tcd9ah5se", "customEventType": "PURCHASE" } } ``` > **The event has to exist first:** > > Reddit only optimizes toward an event the pixel has already seen. Before a `conversions` or `lead_generation` create, Pozy checks when the pixel last received it. If it never did, the call returns `422 invalid_resource_state`. Fire the event from your site, or send one through the Conversions API, then retry. Both goals use Reddit's 7-day click, 1-day view attribution window. Reddit retired its native lead forms on September 30, 2026, so `lead_generation` drives traffic to your own page and `leadGenFormId` returns `400`. ### Budget, schedule and bidding - The budget sits on the ad group. `daily` spends up to `budgetAmount` per day; `lifetime` spends it over the flight and needs `endDate`. - `startDate` defaults to now. A date or time without an offset is read in the ad account's time zone, and a date-only `endDate` runs to 23:59:59 that day. - New ad groups bid automatically. Set a manual bid or a cost cap after creation (see below). ## Promote an existing post `POST /v1/ads/boost` promotes a Reddit post that already exists, with its votes and comments. Pass the Reddit post id as `platformPostId`, with or without the `t3_` prefix. Targeting goes inside the `targeting` object. ```bash curl -X POST https://api.pozy.ai/v1/ads/boost \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: boost-t3-1o4x9qz" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "a2_gu5tcd9ah5se", "platformPostId": "t3_1o4x9qz", "name": "Boost launch thread", "goal": "traffic", "budget": { "amount": 30, "type": "daily" }, "linkUrl": "https://example.com/planner", "targeting": { "countries": ["US"], "communities": ["productivity"] } }' ``` The post you boost must come from a profile that belongs to the ad account. Boosting someone else's post fails with `422` and `param: "platformPostId"`; add that author as a profile of the ad account in Reddit Ads Manager, then try again. The `video_views` goal only works on a video post. ## Targeting | Field | Values | | --- | --- | | `countries` | ISO codes. With no location at all, the ad targets the US. | | `regions`, `metros`, `cities`, `zips` | `{ "key": "..." }` from targeting search, `dimension=geo` | | `excludedLocations` | `countries`, `regions`, `cities` and `zips` only | | `gender` | `all`, `male` or `female` | | `languages` | Codes such as `en` or `de` | | `targeting.interests` | `{ "id": "..." }` from targeting search, `dimension=interest` | | `targeting.communities`, `targeting.excludedCommunities` | Subreddit names, with or without `r/` | | `targeting.conversationKeywords`, `targeting.excludedConversationKeywords` | Free text | | `audienceInclude`, `audienceExclude` | Ids of custom audiences built in Reddit Ads Manager | Age, income, behaviors and radius targeting return `400` naming the field. Custom audiences cannot be created or uploaded through the API (`POST /v1/ads/audiences` returns `422` on a `redditads` connection), so build them in Reddit and target them by id. Search Reddit's catalog with `GET /v1/ads/targeting/search` and `dimension` set to `geo`, `interest`, `community` or `conversationKeyword`: ```bash curl "https://api.pozy.ai/v1/ads/targeting/search?accountId=66b2e19d8c3f5a7e9d0b1c2d&q=gaming&dimension=community" \ -H "Authorization: Bearer $POZY_API_KEY" ``` A community result's `id` is the subreddit name to send, with its subscriber count in `audienceSize`. On `geo`, `geoType` is `city` (default), `zip`, `region`, `metro_area`, `country` or `all`. ## Manage a running campaign Campaigns, ad groups and ads each have their own status on Reddit. Pozy changes only the one you address. | Change | Call | Notes | | --- | --- | --- | | Pause or resume | `PUT /v1/ads/{adId}/status`, `/v1/ads/ad-sets/{adSetId}/status` or `/v1/ads/campaigns/{campaignId}/status` | Only that level's switch changes. | | Rename | `name` on `PUT /v1/ads/{adId}`, `/v1/ads/ad-sets/{adSetId}` or `/v1/ads/campaigns/{campaignId}` | | | Budget | `budget: { amount, type }` on `PUT /v1/ads/{adId}` or `PUT /v1/ads/ad-sets/{adSetId}` | A daily budget stays daily and a lifetime one stays lifetime; trying to switch gets `422`. | | Bid | `bidStrategy` and `bidAmount` on `PUT /v1/ads/ad-sets/{adSetId}` | Strategies: `COST_CAP`, `LOWEST_COST_WITH_BID_CAP`, `LOWEST_COST_WITHOUT_CAP` | | Targeting | `targeting` on `PUT /v1/ads/{adId}` | Replaces the whole set. A field you leave out is cleared (locations fall back to the US). | | Stop | `DELETE` on the ad, ad group or campaign | Archives on Reddit and marks Pozy's copy `cancelled`. | `GET /v1/ads/ad-sets/{adSetId}/targeting` reads an ad group's current targeting and schedule before you replace it. Creative edits are not available (`501 feature_not_available`); create a new ad instead. `POST /v1/ads/campaigns` creates a campaign on its own. With a `budget` it becomes a campaign-budget campaign, where the budget changes through `PUT /v1/ads/campaigns/{campaignId}` rather than the ad group. Adding an ad to an existing campaign or ad group (`existingCampaignId`, `adSetId`) is not available on Reddit. Pozy also imports the ads you build directly in Reddit Ads Manager: `GET /v1/ads?platform=reddit` shows all ads of the synced ad accounts, wherever they were made. Once Reddit drops an archived object from its lists, Pozy marks it `cancelled`. ## Measure results For each ad and each day (in the ad account's time zone), Pozy stores impressions, clicks and spend, and works out CTR, CPC and CPM from them. `GET /v1/ads/{adId}/analytics` returns one ad; `GET /v1/ads/tree` sums them up the campaign tree. - Those three metrics are the whole sync. `reach`, video and conversion metrics read `0` on Reddit. - On connect, Pozy imports the last 90 days. - Demographic `breakdowns` are not available. ### Conversions API Pixels are created in Reddit Ads Manager; the tracking tags endpoints do not manage Reddit pixels. Server-side events go to `POST /v1/ads/conversions` with the pixel id as `destinationId`: ```bash curl -X POST https://api.pozy.ai/v1/ads/conversions \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "destinationId": "a2_gu5tcd9ah5se", "events": [{ "eventName": "Purchase", "eventTime": 1798804800, "eventId": "order_12345", "value": 25.99, "currency": "USD", "user": { "email": "customer@example.com", "clickIds": { "rdt_cid": "1234567890abcdef" } } }] }' ``` - Each event needs a match key: `clickIds.rdt_cid`, `clickIds.rdt_uuid`, an email, a phone, an `externalId`, or the IP address with the user agent. - `eventTime` is Unix seconds. Reddit accepts events up to 7 days old. - `eventId` is Reddit's dedupe key against the same event fired by the pixel. - The connection needs `adsconversions`. Without it the call returns `403 reconnect_required`. [Conversions](https://pozy.ai/docs/ads/conversions) covers event names, hashing and batching across networks. ## Rate limits Reddit limits requests per connected Reddit user. When Reddit answers `429` with a short wait, Pozy waits and retries up to twice for you. A longer wait comes back as `429 rate_limited` with a `Retry-After` header. Your workspace's own [rate limit](https://pozy.ai/docs/getting-started/rate-limits) applies on top. ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `422 feature_not_available` ("has no pixel" or "has no profile") | The ad account is missing a pixel or a profile | Create it in Reddit Ads Manager, then retry. | | `422 invalid_resource_state` | The campaign optimizes for an event the pixel has not seen yet | Trigger that event on your site, or report it with the Conversions API. | | `400` with `param: "promotedObject.customEventType"` | A `conversions` goal with no event, or one Reddit does not support | Pick an event from the list above. | | `400` with `param: "promotedObject.pixelId"` | The pixel belongs to a different ad account | Send a pixel owned by `adAccountId`. | | `400` with `param: "linkUrl"` | `linkUrl` on a text ad | Drop the link, or switch to an image, video or carousel ad. | | `400` with `param: "headline"` | No headline, or one longer than 300 characters | Add or trim the headline. | | `400` with `param: "endDate"` | Lifetime budget, no end date | Add `endDate`. | | `422` with `param: "budget"` | You tried to change the budget type, or set an ad group budget inside a campaign-budget campaign | Keep the current type, or edit the campaign's budget instead. | | `403 reconnect_required` | The connection lacks `adsconversions` | Run the connect call again. | | `429 rate_limited` | Reddit's per-user limit | Wait `Retry-After` seconds. | | `502 platform_api_error` | Reddit refused the call | The message carries Reddit's own explanation. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}/ads`](https://pozy.ai/docs/api/connectAds) | Connect ads for a platform | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`POST /v1/ads/campaigns`](https://pozy.ai/docs/api/createAdCampaign) | Create a standalone campaign | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads`](https://pozy.ai/docs/api/listAds) | List ads | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/ad-sets/{adSetId}/status`](https://pozy.ai/docs/api/updateAdSetStatus) | Pause or resume a single ad set | | [`GET /v1/ads/ad-sets/{adSetId}/targeting`](https://pozy.ai/docs/api/getAdSetTargeting) | Read a Reddit ad group's targeting and schedule | | [`PUT /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/updateAdCampaign) | Update a campaign | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`DELETE /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/deleteAdSet) | Delete an ad set | | [`DELETE /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/deleteAdCampaign) | Delete a campaign | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options | | [`GET /v1/accounts/{accountId}/conversion-destinations`](https://pozy.ai/docs/api/listConversionDestinations) | List conversion destinations | | [`POST /v1/ads/conversions`](https://pozy.ai/docs/api/sendConversions) | Send conversion events | ## Webhook events | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | --- # ChatGPT Ads Run chat card campaigns in ChatGPT from an openaiads connection, connected with an Ads Manager API key, and measure them with the pixel and server-side conversions. ChatGPT Ads shows a chat card (a title, a short body, an image and a link) inside ChatGPT. You connect it with an API key instead of OAuth, create a campaign in one request, and measure it with a pixel on your site plus server-side conversions. The connection platform is `openaiads` and the ad endpoints call the network `openai`. | | | | --- | --- | | Connection platform | `openaiads` | | `platform` on ad endpoints | `openai` | | Structure | Campaign, ad group, chat card ad, all created by one call | | Goals | `traffic`, `awareness`, `conversions` | | Title | `headline`, 3 to 50 characters, required | | Body | Up to 100 characters, required | | Creative | A static image (`imageUrl`) and a link (`linkUrl`), both required | | Budget | `daily` or `lifetime`, at least 1 in the ad account currency | | Targeting | Country only | | Changes after launch | Status and budget | | Measurement | Pixel plus Conversions API | | Dry run | `validateOnly: true` | Only businesses based in the US can use ChatGPT Ads Manager. The ads are shown to people on the ChatGPT Free and Go plans in the United States, Canada, Australia and New Zealand. ## Connect with an API key Create a key in ChatGPT Ads Manager under **Settings**. A key belongs to exactly one ad account, so connect each ad account with its own key. > **The key can write:** > > Ads Manager has no read-only key. The key you paste can create and change campaigns, and Pozy uses it to read performance and to manage the campaigns you create through Pozy. Campaigns built in Ads Manager stay editable there. ```bash cURL curl -X POST https://api.pozy.ai/v1/connect/openai-ads/credentials \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "apiKey": "sk-ads-...", "profileId": "66b2e19d8c3f5a7e9d0b1c2a" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/openai-ads/credentials", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ apiKey: process.env.CHATGPT_ADS_KEY!, profileId: "66b2e19d8c3f5a7e9d0b1c2a" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.accountId, data.adAccountName); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/connect/openai-ads/credentials", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"apiKey": os.environ["CHATGPT_ADS_KEY"], "profileId": "66b2e19d8c3f5a7e9d0b1c2a"}, ) print(resp.json()) ``` ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountName": "Acme US" } ``` Before saving, Pozy tests the key on the ad account. Sending a new key for an ad account you already connected updates that connection rather than adding another. The dashboard can connect it too. | Response | Meaning | | --- | --- | | `401 invalid_credentials` | The key cannot read an ad account. Create a new one in Ads Manager. | | `402 payment_required` | The workspace is past its free connected accounts with no payment method. `reason` is `free_tier_exceeded` and `dashboard_url` is where to add one. | ## Create a campaign `POST /v1/ads/create` builds the campaign, ad group and chat card. This example runs a lifetime budget with a fixed bid until the end date: ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: planner-launch-chatgpt" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "acct_abc123", "name": "Planner launch", "goal": "traffic", "budgetAmount": 25, "budgetType": "lifetime", "endDate": "2027-02-28T23:59:00Z", "bidStrategy": "LOWEST_COST_WITH_BID_CAP", "bidAmount": 2, "headline": "Try the new planner", "body": "Tasks, docs and meetings in one place.", "imageUrl": "https://cdn.example.com/planner.png", "linkUrl": "https://example.com/planner", "countries": ["US"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "planner-launch-chatgpt", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "acct_abc123", name: "Planner launch", goal: "traffic", budgetAmount: 25, budgetType: "lifetime", endDate: "2027-02-28T23:59:00Z", bidStrategy: "LOWEST_COST_WITH_BID_CAP", bidAmount: 2, headline: "Try the new planner", body: "Tasks, docs and meetings in one place.", imageUrl: "https://cdn.example.com/planner.png", linkUrl: "https://example.com/planner", countries: ["US"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "planner-launch-chatgpt", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "acct_abc123", "name": "Planner launch", "goal": "traffic", "budgetAmount": 25, "budgetType": "lifetime", "endDate": "2027-02-28T23:59:00Z", "bidStrategy": "LOWEST_COST_WITH_BID_CAP", "bidAmount": 2, "headline": "Try the new planner", "body": "Tasks, docs and meetings in one place.", "imageUrl": "https://cdn.example.com/planner.png", "linkUrl": "https://example.com/planner", "countries": ["US"], }, ) print(resp.json()["ad"]["status"]) ``` ```json { "ad": { "_id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Planner launch", "platform": "openai", "status": "pending_review", "adType": "standalone", "goal": "traffic", "budget": { "amount": 25, "type": "lifetime" } }, "message": "Ad created" } ``` `budgetAmount` and `bidAmount` are whole units of the ad account currency; the ad's `currency` field names it. ### Budget A ChatGPT campaign has exactly one spend cap: | `budgetType` | Behavior | | --- | --- | | `daily` | A daily cap. Required for automatic bidding. | | `lifetime` | A total cap. Send `endDate` to set the window it is spent over. | The minimum is 1 in the ad account currency (`422` below that). Some currencies have a higher daily minimum, which is only checked on a real create; the error names the amount. ### Bidding | `bidStrategy` | Bidding | Needs | | --- | --- | --- | | Omitted or `LOWEST_COST_WITHOUT_CAP` | Automatic: most clicks on `traffic`, most conversions on `conversions` | `budgetType: "daily"` | | `LOWEST_COST_WITH_BID_CAP` or `COST_CAP` | A fixed bid (the cost per action under `conversions`) | `bidAmount` | Automatic bidding on a lifetime budget or on `awareness` returns `400 missing_required_field` with `param: "bidStrategy"`; send a fixed bid there. There is no ROAS bidding, so `LOWEST_COST_WITH_MIN_ROAS` returns `422`. ### Status at launch With `status: "PAUSED"`, only the campaign is paused and its ad group and ad are left on, so a single `PUT /v1/ads/campaigns/{campaignId}/status` with `active` starts it all. Add `campaignStatus: "ACTIVE"` to flip that around: the campaign runs and the ad group holds the pause. Send neither and the ad starts as soon as it passes review. ### Targeting Countries are the only targeting. Ages, genders, interests and audiences return `400` instead of being ignored. Custom audiences are managed in Ads Manager only. ## Optimize for a conversion `goal: "conversions"` needs an optimizable conversion event on the ad account before you create the campaign. Create one with the pixel (`defaultEventType`, below) or in Ads Manager. Pick the event with `promotedObject.customEventType`. Read the candidates from `conversionEvents[]` on `GET /v1/accounts/{accountId}/conversion-destinations`: ```json { "conversionEvents": [ { "id": "evs_123", "name": "Purchase", "eventType": "order_created", "customEventName": null, "archived": false, "optimizable": true } ] } ``` Pass the event's `id`, its `eventType` (such as `order_created`) or its `name` (such as `Purchase`): ```json { "goal": "conversions", "promotedObject": { "customEventType": "order_created" } } ``` - Only `optimizable` events qualify: active, standard events, not custom ones. - If the event is unknown, archived or custom, you get `400` with the events the account can optimize for. If several events share the name, the `400` asks you to send the `id`. - Without `customEventType`, the campaign optimizes for the most recently created optimizable event. - `customEventType` is the only `promotedObject` field ChatGPT Ads accepts, and only with `conversions`. ## Click tracking Every click adds `tracking.urlTags` to the end of `linkUrl`. Pozy leaves ChatGPT's macros `{campaign_id}`, `{ad_group_id}`, `{ad_id}` and `{oppref}` (the click id) unescaped there, so ChatGPT fills them in on the click. Macros written into `linkUrl` itself stay as literal text. ```json { "tracking": { "urlTags": [ { "key": "utm_source", "value": "chatgpt" }, { "key": "utm_campaign", "value": "{campaign_id}" }, { "key": "click_id", "value": "{oppref}" } ] } } ``` ## Check before you spend Send `validateOnly: true` to test a request without creating anything. Pozy checks copy lengths, budget, bid strategy and targeting, and looks up the conversion event and countries live. ```json { "validateOnly": true, "results": [ { "node": "campaign", "status": "validated" }, { "node": "adSet", "status": "validated" }, { "node": "ad", "status": "validated" } ], "message": "..." } ``` Checks that only run at write time, such as fetching the image, currency minimums and ad review, still happen on the real create. `POST /v1/ads/campaigns` also takes `validateOnly`. ## Manage a running campaign | Change | Call | Notes | | --- | --- | --- | | Pause or resume a campaign | `PUT /v1/ads/campaigns/{campaignId}/status` with `{ "status": "active", "platform": "openai" }` | Pozy reads the switch first; if it already matches, nothing is written (`updated: 0, skipped: 1`). | | Pause or resume one ad | `PUT /v1/ads/{adId}/status` | | | Change the budget | `budget: { amount, type }` on `PUT /v1/ads/{adId}` or `PUT /v1/ads/campaigns/{campaignId}` | Overwrites the one budget cap. A lifetime budget may become daily, but a daily one cannot become lifetime (`422`). | | Rename an ad group or change its bid | `PUT /v1/ads/ad-sets/{adSetId}` | | | Create a campaign on its own | `POST /v1/ads/campaigns` | Needs a budget (`422` without one). Takes `goal` and `promotedObject.customEventType`. | | Stop | `DELETE /v1/ads/{adId}` | There is no delete on ChatGPT Ads. Pozy archives the ad, its ad group and its campaign, and marks the ad `cancelled`. Archiving is final. | Targeting and creative cannot change after launch (`501 feature_not_available`). There is no boost, because ChatGPT has no organic posts, and no video. `GET /v1/ads/tree` reports two kinds of status on ChatGPT Ads: `status` (is it delivering, inherited from parents) and the levels' own switches (`platformCampaignStatus` on campaigns, `configuredStatus` on ads, each `active`/`ACTIVE`, `paused` or `archived`). Objects archived in Ads Manager settle as archived in Pozy. ## Measure results Pozy syncs impressions, clicks and spend once a day and derives CTR, CPC and CPM. `GET /v1/ads/{adId}/analytics` defaults to the last 90 days. Every other metric, `reach` included, reads `0`, and demographic breakdowns are not available. History starts 90 days before the connection. ### The pixel The pixel measures what people do on your site after a chat card. Manage it through the tracking tags endpoints on the `openaiads` connection. ```bash curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "acct_abc123", "name": "Acme website", "defaultEventType": "order_created" }' ``` - One call creates the pixel and a Conversions API key for it. `defaultEventType` also creates the conversion event a `conversions` campaign needs. - `adAccountId` is required by the endpoint but the API key already decides the ad account. - Each pixel has two ids, and per-pixel calls accept either: `tag.id` (`cds_...`) and `tag.siteTagId` (also `pixelId`), the id the site code and the Conversions API send. - Pozy creates one pixel per connection; a second create returns `409`. Creating is not idempotent and pixels cannot be deleted, so never retry it automatically. After a `502`, list the pixels first. - `GET .../tracking-tags/{tagId}/stats` shows the latest events received, up to 50 from the last 15 minutes. Use it to check the pixel fires, not for totals. Conversion events take `name`, `type` and `siteEvent`, with ChatGPT's fixed 30-day window. `type` is a standard event (`order_created`, `lead_created`, `items_added`, `contents_viewed`, `checkout_started`, `registration_completed`, `subscription_created`, `trial_started`, `appointment_scheduled`, `page_viewed`, `app_installed`, `app_opened`) or a custom name of 1 to 64 letters, digits, `_` or `-`. To edit or remove an event, or the pixel, use Ads Manager: the API cannot. Install the pixel on a connected Shopify, WordPress, Wix or Webflow site with one call. See [Pixels](https://pozy.ai/docs/ads/pixels). ### Conversions API Server-side events go to `POST /v1/ads/conversions` with the pixel's site id as `destinationId`. A pixel must exist first (`422 tracking_tag_required` otherwise). ```bash curl -X POST https://api.pozy.ai/v1/ads/conversions \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "destinationId": "4Kx9TqLm2VbN7wRz1PsYcD", "events": [{ "eventName": "Purchase", "eventTime": 1798804800, "eventId": "order_12345", "value": 25.99, "currency": "USD", "user": { "email": "customer@example.com" } }] }' ``` `Purchase`, `Lead`, `AddToCart`, `ViewContent`, `InitiateCheckout`, `CompleteRegistration`, `Subscribe`, `StartTrial` and `Schedule` map to ChatGPT's own event types (`Purchase` arrives as `order_created`). Any other name is sent as a custom event. Events go in chunks of 1,000, and one malformed event fails its chunk. See [Conversions](https://pozy.ai/docs/ads/conversions). ## Limits | Limit | Value | | --- | --- | | Title | 3 to 50 characters | | Body | Up to 100 characters | | Budget | At least 1 in the ad account currency | | Events per conversions request | 1,000 per chunk | | ChatGPT Ads rate limits | 600 requests per minute per endpoint, 1,200 per minute overall | ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `401 invalid_credentials` | The key cannot read an ad account | Create a new key in Ads Manager. | | `400` on a `conversions` create | No optimizable event, or an unknown, archived, custom or ambiguous `customEventType` | Create the event, or pass its `id`. | | `400 missing_required_field` (`bidStrategy`) | Automatic bidding combined with a lifetime budget or the `awareness` goal | Switch to a daily budget, or set a fixed bid. | | `400` on a targeting field | Targeting other than `countries` | Leave it out. | | `422` with `param: "budget.amount"` | Budget below 1 | Raise it. | | `422` with `param: "budget.type"` | A lifetime budget on a campaign with a daily cap | Keep daily, or create a new campaign. | | `422 tracking_tag_required` | Conversions sent before a pixel exists | Create the pixel. | | `422 feature_not_available` | Pixel management is not switched on for the ad account | Ask your ChatGPT Ads contact to turn it on. | | `501 feature_not_available` | Targeting or creative edits after launch | Create a new ad. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/connect/openai-ads/credentials`](https://pozy.ai/docs/api/connectOpenAIAdsCredentials) | Connect an OpenAI Ads account | | [`GET /v1/ads/accounts`](https://pozy.ai/docs/api/listAdAccounts) | List ad accounts | | [`POST /v1/ads/create`](https://pozy.ai/docs/api/createStandaloneAd) | Create standalone ad | | [`POST /v1/ads/campaigns`](https://pozy.ai/docs/api/createAdCampaign) | Create a standalone campaign | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`PUT /v1/ads/{adId}`](https://pozy.ai/docs/api/updateAd) | Update ad | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | | [`PUT /v1/ads/ad-sets/{adSetId}`](https://pozy.ai/docs/api/updateAdSet) | Update an ad set | | [`PUT /v1/ads/campaigns/{campaignId}`](https://pozy.ai/docs/api/updateAdCampaign) | Update a campaign | | [`PUT /v1/ads/campaigns/{campaignId}/status`](https://pozy.ai/docs/api/updateAdCampaignStatus) | Pause or resume a campaign | | [`DELETE /v1/ads/{adId}`](https://pozy.ai/docs/api/deleteAd) | Cancel an ad | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/listTrackingTags) | List tracking tags | | [`POST /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/createTrackingTag) | Create a tracking tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}`](https://pozy.ai/docs/api/getTrackingTag) | Get a tracking tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/stats`](https://pozy.ai/docs/api/getTrackingTagStats) | Get aggregated event stats | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/events`](https://pozy.ai/docs/api/createTrackingTagEvent) | Create a conversion event | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/install`](https://pozy.ai/docs/api/installTrackingTagOnStore) | Install on a Shopify, WordPress, Wix or Webflow site | | [`GET /v1/accounts/{accountId}/conversion-destinations`](https://pozy.ai/docs/api/listConversionDestinations) | List conversion destinations | | [`POST /v1/ads/conversions`](https://pozy.ai/docs/api/sendConversions) | Send conversion events | ## Webhook events | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | --- # Boost posts Turn a published Facebook, Instagram, TikTok, LinkedIn, Pinterest, X or Reddit post into an ad that keeps its likes and comments. Boosting runs a post you already published as an ad. The post keeps its likes, comments and shares, and Pozy builds the campaign, ad set and ad around it. One endpoint, `POST /v1/ads/boost`, works on every network that has organic posts. Google Ads and ChatGPT Ads have none, so they have no boost. ## Boost a post Pass the post, the ad connection, the ad account, a goal and a budget. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/boost \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: boost-65f1c0a9-launch" \ -d '{ "postId": "65f1c0a9e2b5af0012ab34cd", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Launch post boost", "goal": "traffic", "budget": { "amount": 25, "type": "daily" }, "schedule": { "startDate": "2026-11-03T09:00:00Z", "endDate": "2026-11-10T09:00:00Z" }, "targeting": { "countries": ["US", "CA"], "ageMin": 25, "ageMax": 45 }, "linkUrl": "https://example.com/launch", "callToAction": "LEARN_MORE" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/boost", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "boost-65f1c0a9-launch", "Content-Type": "application/json", }, body: JSON.stringify({ postId: "65f1c0a9e2b5af0012ab34cd", accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "act_1234567890", name: "Launch post boost", goal: "traffic", budget: { amount: 25, type: "daily" }, schedule: { startDate: "2026-11-03T09:00:00Z", endDate: "2026-11-10T09:00:00Z" }, targeting: { countries: ["US", "CA"], ageMin: 25, ageMax: 45 }, linkUrl: "https://example.com/launch", callToAction: "LEARN_MORE", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad._id, data.ad.reviewStatus); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/boost", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "boost-65f1c0a9-launch", }, json={ "postId": "65f1c0a9e2b5af0012ab34cd", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Launch post boost", "goal": "traffic", "budget": {"amount": 25, "type": "daily"}, "schedule": {"startDate": "2026-11-03T09:00:00Z", "endDate": "2026-11-10T09:00:00Z"}, "targeting": {"countries": ["US", "CA"], "ageMin": 25, "ageMax": 45}, "linkUrl": "https://example.com/launch", "callToAction": "LEARN_MORE", }, timeout=300, ) resp.raise_for_status() print(resp.json()["ad"]["_id"]) ``` The response is the same `{ ad, message }` as a create, with `adType: "boost"`. On Meta, `ad.creative.effectiveObjectStoryId` points at the original post. ### Which post | Field | Use it for | | --- | --- | | `postId` | A post published through Pozy. Pozy looks up the network's post id. | | `platformPostId` | A post published outside Pozy, by its id on the network. On Reddit the `t3_` prefix is optional. On LinkedIn use the `share` or `ugcPost` id from "Copy link to post", not an `activity` id. | ### The body shape A boost nests its settings, unlike the flat `POST /v1/ads/create` body. Do not mix the two. | Field | Meaning | | --- | --- | | `budget` | `{ amount, type }`, `type` is `daily` or `lifetime`. Whole currency units. | | `schedule` | `{ startDate, endDate }` as date-times. A lifetime budget needs `endDate`. | | `targeting` | Countries, regions, cities, ZIPs, metros, custom locations, age, gender, languages and interests. Which ones apply depends on the network: see [Targeting](https://pozy.ai/docs/ads/targeting). | | `linkUrl` + `callToAction` | A clickable destination on the boosted post. Send them together. | | `status` | `PAUSED` creates everything paused so you can check it first. The default publishes at once. | | `adSetId` | Put the boost under an ad set that already runs. That ad set then owns budget, schedule and targeting, so sending them returns `400`. | | `bidStrategy`, `bidAmount`, `roasAverageFloor` | Optional bidding, same names as on create. LinkedIn bidding goes in `platformSpecificData`. | ## Goals by network | Network | Goals a boost accepts | Notes | | --- | --- | --- | | Meta (Facebook, Instagram) | `engagement`, `traffic`, `awareness`, `video_views`, `lead_generation`, `conversions`, `app_promotion` | `lead_generation` needs `leadGenFormId`. | | TikTok (Spark Ads) | Same as create | `targeting.countries` is required. | | LinkedIn | `engagement`, `traffic`, `awareness`, `video_views`, `lead_generation`, `conversions` | `conversions` is boost-only on LinkedIn. | | Pinterest | `engagement`, `traffic`, `awareness`, `video_views` | Minimum budget $5. | | X | `engagement`, `traffic`, `awareness`, `video_views`, `app_promotion` | The boost carries the post's media, video included. It is the way to run video on X. | | Reddit | Same as create | Only posts by a profile of the ad account. `video_views` needs a video post. | ## Network notes ### Meta - `destinationType` picks where a click lands: `WEBSITE`, `MESSENGER`, `WHATSAPP`, `INSTAGRAM_DIRECT`, `INSTAGRAM_PROFILE` or `ON_AD`. - To open a chat instead of a web page, use `goal: "engagement"` with `callToAction` set to `WHATSAPP_MESSAGE`, `MESSAGE_PAGE` or `INSTAGRAM_MESSAGE`, and leave `linkUrl` out. `whatsappPhoneNumber` picks a number paired with the Page. `workflowId` starts one of your [workflows](https://pozy.ai/docs/automations/workflows) in that chat. - `VIEW_INSTAGRAM_PROFILE` is a boost-only button. Use the profile URL as `linkUrl`. - `instagramAccountId` picks the Instagram identity the ad runs as. `GET /v1/ads/instagram-posts` lists Instagram posts you can boost. - Boosting an Instagram video can take minutes while Meta re-hosts it. Do not retry on a client timeout. ### TikTok A boost on TikTok is a Spark Ad. To promote another creator's video, send their Spark Code as `sparkAuthCode` with the video id in `platformPostId`. Several creators go in `sparkPosts[]`. Details on [TikTok Ads](https://pozy.ai/docs/ads/tiktok-ads#spark-ads). ### LinkedIn Only Company Page posts can be boosted. A personal-profile post returns `422` with `INVALID_URN_TYPE`, and a Page post that is not approved for sponsorship returns `422` until the author approves it. To sponsor a Page post under a campaign whose bidding and targeting you control, use the thought leader format on create instead ([LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads#creative-formats)). ### X and Reddit On X, a boost can only change status, budget and bid afterwards. On Reddit, link the post's author to the ad account in Reddit Ads Manager first, or the boost returns `422` with `param: "platformPostId"`. ## Retries Boosts are not idempotent on the networks' side. Send an `Idempotency-Key` header: the same key and body replays the original response, and distinct keys always create distinct ads. Without the header, Pozy still protects you from obvious duplicates: - an identical request while the first is still running returns `409` - an identical request within 10 minutes of a finished boost returns the ad already created To boost the same post twice on purpose, use different keys. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `409` | An identical boost is still in progress | Wait, then read the ad from `GET /v1/ads`. | | `422` `ads_connection_required` | The profile has no ad connection for that network | [Connect the ad account](https://pozy.ai/docs/ads#connect-an-ad-account). | | `422` `linked_account_required` | An Instagram Login account was used on Meta | Use a `facebook` or `metaads` account in the same profile. | | `400` on `callToAction` | A button without `linkUrl` | Send both, or use a messaging button on Meta. | | `400` with `adSetId` | Budget, schedule or targeting sent alongside `adSetId` | Leave them out; the ad set owns them. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/ads/boost`](https://pozy.ai/docs/api/boostPost) | Boost post as ad | | [`GET /v1/ads/instagram-posts`](https://pozy.ai/docs/api/listAdsInstagramPosts) | List Instagram posts to boost | | [`GET /v1/ads/instagram-accounts`](https://pozy.ai/docs/api/listAdsInstagramAccounts) | List Instagram ad identities | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`PUT /v1/ads/{adId}/status`](https://pozy.ai/docs/api/updateAdStatus) | Pause or resume a single ad | --- # Targeting Target ads by location, age, interests, keywords, job titles or subreddits, look up each network's ids with one search endpoint and estimate reach first. Targeting tells a network who should see an ad. Pozy uses one set of field names across networks, then translates them into each network's own targeting spec. Portable fields such as `countries`, `ageMin`, `ageMax`, `gender` and `languages` work everywhere. The rest only mean something on the network they were looked up for, and a field a network cannot honor is rejected with a `400` that names it. ## Where targeting goes | Call | Shape | | --- | --- | | `POST /v1/ads/create` | Flat fields at the top level (Meta, TikTok, Pinterest, X, Reddit), or a nested `targeting` object (LinkedIn, Google accepts both) | | `POST /v1/ads/boost` | Inside `targeting` | | `POST /v1/ads/messaging`, `POST /v1/ads/call` | Flat fields, like create | | `PUT /v1/ads/{adId}` | `targeting` updates the ad's ad set or campaign where the network allows it | | `PUT /v1/ads/campaigns/{campaignId}/targeting` | Google campaign-level devices, locations and languages | | `POST /v1/ads/targeting/reach-estimate` | Inside `spec` | ## Look up ids Cities, regions, interests, job titles and subreddits are ids that belong to each network. Find them with `GET /v1/ads/targeting/search`, using the `accountId` of the ad connection you will create the ad on. ```bash cURL curl -G https://api.pozy.ai/v1/ads/targeting/search \ -H "Authorization: Bearer $POZY_API_KEY" \ --data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c2d" \ --data-urlencode "q=Amsterdam" \ --data-urlencode "dimension=geo" \ --data-urlencode "geoType=city" \ --data-urlencode "countryCode=NL" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/targeting/search?accountId=66b2e19d8c3f5a7e9d0b1c2d&q=Amsterdam&dimension=geo&geoType=city&countryCode=NL", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const cityKey = data.results[0].id; // send as { key: cityKey } ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/ads/targeting/search", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "66b2e19d8c3f5a7e9d0b1c2d", "q": "Amsterdam", "dimension": "geo", "geoType": "city", "countryCode": "NL"}, ) print(resp.json()["results"][0]) ``` ```json { "results": [ { "id": "2759794", "name": "Amsterdam", "type": "city", "path": ["Netherlands", "North Holland", "Amsterdam"], "audienceSize": null } ] } ``` Send a geo result's `id` as `key` in `cities`, `regions`, `zips` or `metros`. A `country` result's `id` is the ISO code for `countries`. Interest and behavior results go in `interests` and `behaviors` as `{ id, name }`. | `dimension` | Meta | Google | TikTok | LinkedIn | X | Reddit | | --- | --- | --- | --- | --- | --- | --- | | `geo` | Yes | Yes | Yes | Yes | Yes | Yes | | `interest` | Yes | Yes (affinity and in-market) | Yes | Yes | Yes | Yes | | `behavior` | Yes | – | Yes | Yes | – | – | | `language` | – | Yes | – | – | – | – | | `income` | Yes | Yes (`top_10` only) | – | – | – | – | | `workPosition`, `workEmployer`, `workIndustry` | Yes | – | – | – | – | – | | `industry`, `jobFunction`, `seniority`, `companySize`, `skill` | – | – | – | Yes | – | – | | `community`, `conversationKeyword` | – | – | – | – | – | Yes | `geoType` narrows geo searches to `country`, `region`, `city` (the default), `subcity`, `neighborhood`, `place`, `zip`, `metro_area` or `geo_market`. Google and LinkedIn ignore it and return every level, so filter on each result's `type`. `GET /v1/ads/targeting/browse` walks Meta's targeting category tree (by `type` and `parentNodeId`) instead of searching it. `dimension` defaults to `interest`. A dimension the network does not support returns `400` or `404`. ## Estimate reach first `POST /v1/ads/targeting/reach-estimate` returns an audience size range before you create anything: ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "spec": { "countries": ["NL"], "ageMin": 25, "ageMax": 54, "interests": [{ "id": "6003107902433", "name": "Interior design" }] } } ``` ```json { "available": true, "lower": 412000, "upper": 585000, "estimateReady": true } ``` `estimateReady: false` means the network is still computing, which happens with brand-new audiences: retry shortly. Networks without an estimate return `available: false`. On LinkedIn, [bid pricing and supply forecasts](https://pozy.ai/docs/ads/linkedin-ads#forecast-before-you-spend) add bid ranges and projected delivery. ## Meta | Field | Values | | --- | --- | | `countries`, `countryGroups` | ISO codes, or groups such as `eea` or `north_america`. Defaults to `["US"]` only when no geo field is sent. | | `cities` | `{ key, radius, distance_unit }`. Meta's minimum radius is about 17 km (10 miles); smaller resolves to an empty audience. | | `regions`, `zips`, `metros` | `{ key }` from the search, for example `US:94304` or `DMA:807` | | `customLocations` | `{ latitude, longitude, radius, distanceUnit }` | | `excludedLocations` | Countries, regions, cities, ZIPs, places, neighborhoods, custom locations | | `ageMin`, `ageMax` | 13 to 65 | | `gender` | `all`, `male`, `female` | | `interests`, `behaviors` | `{ id, name }` | | `workPositions`, `workEmployers`, `workIndustries` | `{ id, name }` | | `incomeTier` | `top_5`, `top_10`, `top_10_25`, `top_25_50` (household income by ZIP) | | `languages` | `en` targets every variant, `en_GB` one | | `userOs`, `userDevice` | For example `iOS_ver_14.0_and_above`, `iPhone` | | `audienceInclude`, `audienceExclude` | Audience ids ([Audiences](https://pozy.ai/docs/ads/audiences)) | | `placements` | Manual placements. Omit for automatic placements. | | `savedTargetingId` | A saved targeting audience as the base, with inline fields on top | | `rawTargeting` | A Meta-native spec for anything the flat fields do not cover, such as layered `flexible_spec` | | `advantageAudience` | `0` keeps targeting strict, `1` lets Meta expand | > **Locations overlap:** > > Do not send a city together with the country that contains it. Meta rejects the ad. Drop the country, or use a different one. Housing, employment and credit ads (`specialAdCategories`) cannot use income or ZIP targeting. EU delivery needs `dsaBeneficiary` and `dsaPayor`. ## Google | Field | Values | | --- | --- | | `countries` | ISO codes. Search and Display default to `US` with no geo; Performance Max and Demand Gen run worldwide. | | `regions`, `cities`, `zips`, `metros` | `{ key }`, Google's geo target ids | | `customLocations` | A pin with radius (Google has no city radius) | | `excludedLocations` | Countries, regions, cities, ZIPs | | `languages` | ISO 639-1 codes plus variants such as `zh_CN` | | `ageMin`, `ageMax`, `gender` | Search and Display only, 18 and over | | `incomeTier` | `top_10` only | | `interests` | Affinity and in-market segments, Search and Display | | `audienceInclude`, `audienceExclude` | User list ids, such as Customer Match lists | | `keywords`, `negativeKeywords` | Search only, see [Google Ads](https://pozy.ai/docs/ads/google-ads#keywords) | `locationTargetingType` sets whether locations reach people physically there (`presence`) or also people interested in them (`presence_or_interest`, Google's default). Performance Max and Demand Gen take geo and language only. On a running campaign, `PUT /v1/ads/campaigns/{campaignId}/targeting` replaces `devices` (`MOBILE`, `DESKTOP`, `TABLET`, `CONNECTED_TV`, each with an optional bid modifier), `locations`, `excludedLocations` and `languages`. ## TikTok | Field | Values | | --- | --- | | `countries` | ISO codes. Required on boosts. | | `regions`, `cities`, `zips`, `metros` | Create only | | `ageMin`, `ageMax`, `gender`, `languages` | `zh_TW` targets traditional Chinese | | `interests` | From the interest search | | `behaviors` | `video:` or `creator:`. Restricts delivery to the TikTok placement. | | `incomeTier` | As on Meta | | `audienceInclude`, `audienceExclude` | Processed custom audiences | `PUT /v1/ads/{adId}` with `targeting` is a partial update; fields you leave out keep their value, except that a new location replaces both location and ZIP targeting. ## LinkedIn | Field | Values | | --- | --- | | `countries`, `regions` | ISO codes or `urn:li:geo:*`. At least one location; none means the United States. | | `industries`, `seniorities`, `jobFunctions`, `jobTitles`, `skills` | Id fragments or full URNs | | `companySizes` | `A` to `I` staff-count ranges | | `interests`, `behaviors` | URNs from the search | | `audienceInclude`, `audienceExclude` | Matched audience segment ids | | `ageMin`, `ageMax`, `gender`, `languages` | Rounded to LinkedIn's buckets | | `excludedLocations.countries` | ISO codes | `jobTitles` cannot be combined with `seniorities` or `jobFunctions`. Cities, ZIP codes, radius and income return `400`. Updates through `PUT /v1/ads/{adId}` merge facet by facet; send a facet empty to remove it. ## Pinterest `countries`, `regions`, `metros`, `zips`, `ageMin`, `ageMax`, `gender`, `languages`, `interests` (from `GET /v1/ads/interests`) and customer list audiences. `regions` and `zips` cannot be combined. Targeting cannot change after create: recreate the ad. ## X `countries`, `regions`, `metros`, `cities`, `zips`, `interests`, Tailored Audiences, `gender` and `languages`. Age rounds out to X's fixed ranges (starting at 13, 18, 21, 25, 35 or 50 and ending at 24, 34, 49 or 54, or open-ended), so `ageMin: 20, ageMax: 40` targets 18 to 49. Income, custom locations and behaviors are rejected. Targeting cannot change after create. ## Reddit | Field | Values | | --- | --- | | `countries`, `regions`, `metros`, `cities`, `zips` | No location means US | | `excludedLocations` | Countries, regions, cities and ZIPs | | `gender`, `languages` | | | `targeting.interests` | From the interest search | | `targeting.communities`, `targeting.excludedCommunities` | Subreddit names, with or without `r/` | | `targeting.conversationKeywords`, `targeting.excludedConversationKeywords` | Free text | | `audienceInclude`, `audienceExclude` | Custom audiences built in Reddit Ads Manager | Age, income and radius are not available. Targeting can be changed after launch with `PUT /v1/ads/{adId}`. ## ChatGPT ChatGPT ads target by country only. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/interests`](https://pozy.ai/docs/api/searchAdInterests) | Search targeting interests | | [`GET /v1/ads/targeting/search`](https://pozy.ai/docs/api/searchAdTargeting) | Search targeting options | | [`GET /v1/ads/targeting/browse`](https://pozy.ai/docs/api/browseAdTargeting) | Browse targeting categories | | [`POST /v1/ads/targeting/reach-estimate`](https://pozy.ai/docs/api/estimateAdReach) | Estimate audience reach | | [`POST /v1/ads/targeting/bid-pricing`](https://pozy.ai/docs/api/getLinkedInBidPricing) | Suggested bid and budget bounds | | [`POST /v1/ads/targeting/supply-forecast`](https://pozy.ai/docs/api/getLinkedInSupplyForecast) | Forecast ad delivery | --- # Audiences Build customer lists, lookalikes, website, engagement and company audiences on each ad network, upload hashed members and target them. An audience is a set of people you target or exclude. Some you fill yourself by uploading emails (customer lists). Others the network fills from a pixel, a seed list or engagement with your Page or videos. Create every type with `POST /v1/ads/audiences`, then put its `platformAudienceId` in `audienceInclude` or `audienceExclude` on a create. ## Upload a customer list ### Create the audience ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/audiences \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Customers - last 12 months", "type": "customer_list", "description": "Paid at least once since November 2025" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/audiences", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "act_1234567890", name: "Customers - last 12 months", type: "customer_list", description: "Paid at least once since November 2025", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const audienceId = data.audience.id; ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/audiences", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Customers - last 12 months", "type": "customer_list", "description": "Paid at least once since November 2025", }, ) audience_id = resp.json()["audience"]["id"] ``` Creates are not idempotent. Do not retry one automatically: list the audiences first to see whether it exists. ### Upload members Send plaintext `email` (and on Meta, `phone` in E.164) per row, up to 10,000 rows per request. ```bash curl -X POST https://api.pozy.ai/v1/ads/audiences/66e5b2c3d4f5a6b7c8d9e0f1/users \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "users": [ { "email": "maria@example.com" }, { "email": "sam@example.org", "phone": "+14155551234" } ] }' ``` ```json { "message": "Users added", "numReceived": 2, "numInvalid": 0 } ``` ### Target it Read `platformAudienceId` from `GET /v1/ads/audiences?accountId=...&adAccountId=...` and send it in `audienceInclude` or `audienceExclude` on `POST /v1/ads/create`. ### Hashing and matching You send plain values. Pozy normalizes them (trims, lowercases emails, formats phones) and hashes them with SHA-256 before they leave Pozy, so the networks never receive raw contact details. You do not need to hash anything yourself. | Network | Matches on | Per request | Upload behavior | Notes | | --- | --- | --- | --- | --- | | Meta | Email and phone | 10,000 | Adds | Remove people with `DELETE /v1/ads/audiences/{audienceId}/users` (Meta only). | | Google (Customer Match) | Email | 10,000 | Adds | Matching takes 10 minutes to 24 hours. Google only serves a list once enough members match; upload at least 5,000. | | TikTok | Email | 10,000 | Adds | Created on TikTok with the first upload. Takes up to 48 hours before it can be targeted. | | LinkedIn | Email | 10,000 | Replaces the whole list | Every upload is the full list, so a contact list tops out at 10,000 members. | | Pinterest | Email | 10,000 | Adds | Created on Pinterest with the first upload. | | X (Tailored Audiences) | Email | 10,000 | Adds | Needs at least 100 matched active users before it can be targeted; size reads 0 until then. | | Reddit | – | – | – | Build custom audiences in Reddit Ads Manager and target them by id. Creates return `422`. | | ChatGPT | – | – | – | No custom audiences. | A `phone` on a network that matches on email only is ignored. A row that cannot be read counts in `numInvalid`. > **Consent:** > > Only upload people who agreed to be contacted this way. Each network's terms for customer lists apply to you, and the hashed upload does not change that. ## Audience types `type` on `POST /v1/ads/audiences` picks what the audience is built from. | `type` | Network | Built from | Key fields | | --- | --- | --- | --- | | `customer_list` | Meta, Google, TikTok, LinkedIn, Pinterest, X | Emails (and phones on Meta) you upload | `customerFileSource` on Meta | | `lookalike` | Meta | People similar to a seed audience | `sourceAudienceId` (the seed's `platformAudienceId`), `country`, `ratio` 0.01 to 0.2 | | `website` | Meta | Pixel visitors | `pixelId`, `retentionDays`, optional `urlContains` or a raw Meta `rule` | | `meta_engagement` | Meta | People who engaged with a Page, an Instagram account or a video | `engagementSource` (`page`, `instagram`, `video`), `sourceId`, `retentionDays` 1 to 365, optional `event` | | `saved_targeting` | Meta | A stored targeting spec, no members | `spec`. Use it later as `savedTargetingId`. | | `company_list` | LinkedIn | Companies by name, domain, website or Page URL | `companies` (up to 300,000 rows, at least 1,000 recommended) | | `engagement` | LinkedIn | People who engaged with ads, Pages or events | `sourceType`, `trigger`, `lookbackDays` (30 to 365), `engagementSources` (up to 50) | | `website_retargeting` | LinkedIn | Insight Tag visitors matching URL rules | `matchRules` with `matchType` (`EXACT`, `STARTS_WITH`, `CONTAINS`, `ENDS_WITH`), up to 50 rules | | `tiktok_engagement` | TikTok | People who engaged with your ads or videos | `source`, `sourceIds`, `identityId` | | `pinterest_engagement` | Pinterest | People who engaged with your Pins | `engagementType`, `pinIds` | Using a type on the wrong network's account returns `422`. Meta's `meta_engagement` and LinkedIn's `engagement` are different audiences with different fields. ### Lookalikes on Meta Upload members to the seed list first, then create the lookalike from it. Meta builds it from the seed's matched members, so check the seed's `size` with `GET /v1/ads/audiences/{audienceId}` before you create. `ratio: 0.01` is the closest 1% of the country. ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "name": "Lookalike 1% US - customers", "type": "lookalike", "sourceAudienceId": "23851234567890123", "country": "US", "ratio": 0.01 } ``` ### LinkedIn company lists Company lists target or exclude whole companies. Each row needs at least one of `name`, `domain`, `website` or `linkedinPageUrl`. LinkedIn matches rows against its company graph within 48 hours. To change the list, send the complete new list to `POST /v1/ads/audiences/{audienceId}/companies`: it replaces the old one. ### LinkedIn website retargeting The audience fills only once the site has the LinkedIn Insight Tag installed and reporting visits ([Pixels and tags](https://pozy.ai/docs/ads/pixels)). You can create it before the tag is live. ## Manage audiences | Job | Call | | --- | --- | | List with size and status | `GET /v1/ads/audiences?accountId=...&adAccountId=...` | | Read one with fresh numbers | `GET /v1/ads/audiences/{audienceId}` | | Rename or edit | `PUT /v1/ads/audiences/{audienceId}` (name and description; `spec` only on saved targeting) | | Delete | `DELETE /v1/ads/audiences/{audienceId}` | `audience.id` is Pozy's id, used in the paths above. `platformAudienceId` is the network's id, used in targeting and as a lookalike seed. Audiences build asynchronously on every network: a size or member read right after an upload can be empty. Read again after a minute instead of uploading again. ## Errors | Error | Cause | Fix | | --- | --- | --- | | `422` on `/users` | The audience is not a `customer_list` (or is a LinkedIn company list) | Upload rows only to customer lists. Company lists use `/companies`. | | `422` "Audience has no platform ID" | The network has not accepted the audience yet | Read it again and retry once `platformAudienceId` is set. | | `422` on create | The type does not exist on that network | Use a type from the table above. | | `400` with `type: "platform_error"` | The network rejected a rule or source (for example an ineligible engagement source on Meta) | Read the network's message in `platformError`. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/audiences`](https://pozy.ai/docs/api/listAdAudiences) | List custom audiences | | [`POST /v1/ads/audiences`](https://pozy.ai/docs/api/createAdAudience) | Create custom audience | | [`GET /v1/ads/audiences/{audienceId}`](https://pozy.ai/docs/api/getAdAudience) | Get audience details | | [`PUT /v1/ads/audiences/{audienceId}`](https://pozy.ai/docs/api/updateAdAudience) | Update an audience | | [`DELETE /v1/ads/audiences/{audienceId}`](https://pozy.ai/docs/api/deleteAdAudience) | Delete custom audience | | [`POST /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/addUsersToAdAudience) | Add users to audience | | [`DELETE /v1/ads/audiences/{audienceId}/users`](https://pozy.ai/docs/api/removeUsersFromAdAudience) | Remove users from audience | | [`POST /v1/ads/audiences/{audienceId}/companies`](https://pozy.ai/docs/api/replaceAdAudienceCompanies) | Replace audience companies | --- # Conversions API Send purchases, leads and offline conversions from your server to Meta, Google, LinkedIn, TikTok, Pinterest, Reddit and ChatGPT with one event shape. A browser pixel misses a lot: blocked scripts, purchases that close on the phone, deals that sign weeks later in your CRM. Server-side conversions fill the gap. You send the event from your backend once, in one shape, and Pozy delivers it to the ad network's own conversions API, hashed and formatted the way that network expects. Networks then attribute and optimize on it next to what the pixel saw. | Network | Connection | Send events | Notes | | --- | --- | --- | --- | | Meta | `metaads` | Yes | Web, app, offline, CRM and messaging events. Event Match Quality read-back. | | Google Ads | `googleads` | Yes | Click and enhanced conversions. Adjustments (refunds, restatements). | | LinkedIn | `linkedinads` | Yes | Offline conversions to a conversion rule. Attribution read-back. | | TikTok | `tiktokads` | Yes | Offline events only (store, CRM, call center). | | Pinterest | `pinterestads` | Yes | Event Quality Score read-back. | | Reddit | `redditads` | Yes | To a pixel created in Reddit Ads Manager. | | ChatGPT Ads | `openaiads` | Yes | Needs the pixel created first. | | X | `xads` | No | Measure X with the [X Pixel](https://pozy.ai/docs/ads/x-ads#x-pixel). | ## Send your first event ### Find the destination Every network calls the receiving object something different. `GET /v1/accounts/{accountId}/conversion-destinations` lists the ones the ads connection can reach, so you never have to guess the format. ```bash curl https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/conversion-destinations \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "platform": "metaads", "destinations": [ { "id": "1729525464415281", "name": "Acme website pixel", "status": "active", "adAccountId": "act_1234567890" } ] } ``` | Network | `destinationId` is | Example | | --- | --- | --- | | Meta | The pixel (dataset) id | `1729525464415281` | | Google Ads | The conversion action resource name | `customers/1234567890/conversionActions/987654321` | | LinkedIn | The conversion rule id or URN | `104012` | | TikTok | The Offline Event Set id | `7057103914977558530` | | Pinterest | The numeric ad account id | `549755885175` | | Reddit | The pixel id | `a2_gu5tcd9ah5se` | | ChatGPT Ads | The pixel's site id | `4Kx9TqLm2VbN7wRz1PsYcD` | ### Send the event `POST /v1/ads/conversions` takes the ads connection, the destination and up to thousands of events. The connection's platform picks the network. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/conversions \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "destinationId": "1729525464415281", "events": [{ "eventName": "Purchase", "eventTime": 1804064400, "eventId": "order_1042", "value": 129.99, "currency": "USD", "actionSource": "web", "sourceUrl": "https://shop.example.com/checkout/thanks", "user": { "email": "jane@example.com", "phone": "+14155551234", "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)", "clickIds": { "fbc": "fb.1.1804060000.IwAR2...", "fbp": "fb.1.1803990000.1234567890" } }, "items": [{ "id": "sku-981", "name": "Linen throw", "price": 64.99, "quantity": 2 }] }] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/conversions", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", destinationId: "1729525464415281", events: [ { eventName: "Purchase", eventTime: Math.floor(Date.now() / 1000), eventId: "order_1042", value: 129.99, currency: "USD", actionSource: "web", sourceUrl: "https://shop.example.com/checkout/thanks", user: { email: "jane@example.com", phone: "+14155551234", ipAddress: "203.0.113.7", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)", clickIds: { fbc: "fb.1.1804060000.IwAR2...", fbp: "fb.1.1803990000.1234567890" }, }, items: [{ id: "sku-981", name: "Linen throw", price: 64.99, quantity: 2 }], }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); if (data.eventsFailed) console.warn(data.failures); ``` ```python Python import os, time, requests resp = requests.post( "https://api.pozy.ai/v1/ads/conversions", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "destinationId": "1729525464415281", "events": [{ "eventName": "Purchase", "eventTime": int(time.time()), "eventId": "order_1042", "value": 129.99, "currency": "USD", "actionSource": "web", "sourceUrl": "https://shop.example.com/checkout/thanks", "user": { "email": "jane@example.com", "phone": "+14155551234", "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)", "clickIds": {"fbc": "fb.1.1804060000.IwAR2...", "fbp": "fb.1.1803990000.1234567890"}, }, "items": [{"id": "sku-981", "name": "Linen throw", "price": 64.99, "quantity": 2}], }], }, ) body = resp.json() print(body["eventsReceived"], body["failures"]) ``` ### Check the result ```json { "platform": "metaads", "eventsReceived": 1, "eventsFailed": 0, "failures": [], "traceId": "AbCdEf123456" } ``` A rejected event is not an HTTP error. The call returns `200` and lists what did not land in `failures[]`, each with `eventIndex`, `eventId`, `message` and `code`. Always read `eventsFailed`. `traceId` is the network's own request id, useful when you contact that network's support. ## The event shape | Field | Required | What to send | | --- | --- | --- | | `eventName` | Yes | A standard name (`Purchase`, `Lead`, `CompleteRegistration`, `AddToCart`, `InitiateCheckout`, `AddPaymentInfo`, `Subscribe`, `StartTrial`, `ViewContent`, `Search`, `Contact`, `SubmitApplication`, `Schedule`, `PageView`) or your own name. See [event names](#event-names-per-network). | | `eventTime` | Yes | Unix time in **seconds**, UTC. A millisecond value lands thousands of years in the future and is dropped. | | `eventId` | Yes | Your stable id for this conversion, such as the order id. It is the dedupe key. | | `value`, `currency` | No | The conversion value in whole units, and an ISO currency code. | | `user` | Yes | Who converted. See [match keys](#match-keys). | | `items` | No | Line items: `id`, `name`, `price`, `quantity`, `category`. | | `sourceUrl` | No | The page where it happened. | | `actionSource` | No | `web` (default), `app`, `offline`, `crm`, `phone_call`, `system_generated` or `business_messaging`. | | `messaging` | No | For conversations started from an ad: `channel` (`whatsapp`, `messenger`, `instagram_direct`) plus ids such as `ctwaClid` or `pageScopedUserId`. Meta only. | | `platformData` | No | Extra network-specific fields Pozy does not model. On Meta they merge into `custom_data`. | At the request root, `testCode` sends the batch to the network's test tools, and `consent` carries the person's choices. Both are explained below. ## Match keys The more identifiers you send, the more conversions a network can tie back to an ad. Send them in plain text. Pozy normalizes and hashes personal data with SHA-256 to each network's spec before anything leaves Pozy, including Google's rule that strips dots and `+suffix` from Gmail addresses. | Group | Fields under `user` | | --- | --- | | Contact | `email`, `phone` (E.164, with `+`), `firstName`, `lastName` | | Location and demographics | `country`, `city`, `state`, `zip`, `dob` (`YYYYMMDD`), `gender` (`f` or `m`) | | Identifiers | `externalId` (your own user id), `ipAddress`, `userAgent`, `leadId` | | Click ids, under `clickIds` | `fbc`, `fbp` (Meta), `gclid`, `gbraid`, `wbraid` (Google), `li_fat_id` (LinkedIn), `epik` (Pinterest), `rdt_cid`, `rdt_uuid` (Reddit) | `ipAddress`, `userAgent` and click ids are sent unhashed, as the networks require. LinkedIn takes `externalId` in plain text too. > **Capture click ids at the first visit:** > > Click ids arrive on the landing page URL (`fbclid`, `gclid`, `li_fat_id`, `rdt_cid`) or in first-party cookies (`_fbp`, `_epik`, `_rdt_uuid`). Store them with the visitor when they land, then attach them to every later conversion. They are the strongest match signal each network has. Minimum identity per network: | Network | Each event needs at least | | --- | --- | | Meta | Any match key; more is better | | Google Ads | A click id, or an email or phone (enhanced conversions for leads) | | LinkedIn | An email, `li_fat_id`, or both first and last name | | TikTok | An email or a phone | | Pinterest | An email, or the IP address with the user agent | | Reddit | A click id, `rdt_uuid`, email, phone, `externalId`, or IP with user agent | | ChatGPT Ads | An email (the identifier it matches on) | Events missing the minimum are reported in `failures[]` (for example `INVALID_EVENT` or `MISSING_IDENTITY`) and the rest of the batch still goes. ## Deduplicate against the pixel Most sites send the same purchase from the browser pixel and from the server. Use the same `eventId` in both places and the network counts it once. | Network | How the dedupe works | | --- | --- | | Meta | Pixel and server events with the same id merge within 48 hours. | | Google Ads | `eventId` becomes the transaction id, so repeated uploads of one conversion are ignored. | | LinkedIn | When the Insight Tag fires the same id, LinkedIn keeps the tag's event and drops the server copy. | | Pinterest | Matched against the Pinterest tag by event id. | | Reddit | Sent as the conversion id and matched against the Reddit pixel. | | ChatGPT Ads | Matched against the pixel by event id. | > **The usual cause of inflated numbers:** > > A missing `eventId`, or a different id on the pixel and the server, counts the conversion twice. Pozy's store installs (see [Pixels](https://pozy.ai/docs/ads/pixels)) set an event id on browser events so they can be matched. ## Event names per network | Network | How `eventName` is used | | --- | --- | | Meta | Any name. Standard names match Meta's built-in events; anything else is a custom event. | | Google Ads | Ignored. The conversion action decides the category. | | LinkedIn | Ignored. The conversion rule's type decides the category. | | TikTok | Sent as is. | | Pinterest | Standard names map to Pinterest's (`Purchase` is `checkout`, `CompleteRegistration` is `signup`, `PageView` is `page_visit`). Other names match a custom event of that name. | | Reddit | Standard names map to Reddit's (`CompleteRegistration` is `SIGN_UP`, `PageView` is `PAGE_VISIT`). Other names become custom events. | | ChatGPT Ads | Nine names map to its own types (`Purchase` is `order_created`, `Lead` is `lead_created`, `AddToCart` is `items_added`). Other names become custom events. | ## Offline and CRM conversions Set `actionSource` to where it happened and send the identifiers you have from the CRM. - **Meta:** `offline`, `crm`, `phone_call` and `system_generated` are all accepted on the same pixel. - **Google Ads:** create a conversion action for uploads (below), then send events with a `gclid` when you kept one, or a hashed email or phone when you did not. - **LinkedIn:** send to a conversion rule. `eventName` does not matter; the rule's type does. - **TikTok:** the only path through this endpoint. `destinationId` is an Offline Event Set and each event needs an email or phone. ### Create a destination for uploads `POST /v1/accounts/{accountId}/conversion-destinations` creates one on Google Ads and LinkedIn. ```bash curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/conversion-destinations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "1234567890", "name": "Offline purchase", "type": "Purchase" }' ``` - **Google:** `type` is a standard name or a Google category (`PURCHASE`, `SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`). The same name again returns the existing action; the same name with a different category returns `409 idempotency_conflict`. `countingType` (`MANY_PER_CLICK` by default) and `primaryForGoal` (default `false`, so it records without driving bids) are Google-only. - **LinkedIn:** a rule binds a `type` to the destination. By default it attaches to every campaign in the ad account (`autoAssociationType: "ALL_CAMPAIGNS"`); `OBJECTIVE_BASED` attaches only matching campaigns and `NONE` lets you manage it with the associations endpoints. Click and view windows default to 30 and 7 days and take 1, 7, 30, 90 or 365. Creating a rule is not idempotent. On LinkedIn, `GET .../conversion-destinations/{destinationId}/metrics` reads post-click and view-through conversions and value back per rule, with `granularity` of `ALL`, `DAILY`, `MONTHLY` or `YEARLY`. ### Correct a conversion on Google Ads `POST /v1/ads/conversions/adjustments` changes conversions you already uploaded. Google Ads is the only network with this. | `adjustmentType` | Use it for | | --- | --- | | `RETRACTION` | Refunds, chargebacks, cancelled orders | | `RESTATEMENT` | A changed value. Send the new **total** in `restatementValue`, not the difference. | | `ENHANCEMENT` | Adding an email or phone to a conversion after the fact | Identify the original with `orderId`, or `gclid` plus `conversionTime`. `adjustmentTime` is Unix seconds. ## Send once, deliver everywhere When the same purchase should reach every network a customer advertises on, use the profile-level event stream instead of calling each destination. ### Save the destinations `POST /v1/events/destinations` links an ads connection and destination to a profile. `eventNames` limits what it receives, for example only `Purchase`. Leave it out to forward everything. ```bash curl -X POST https://api.pozy.ai/v1/events/destinations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "66b2e19d8c3f5a7e9d0b1c2a", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "destinationId": "1729525464415281", "eventNames": ["Purchase", "Lead"] }' ``` ### Send events to the profile `POST /v1/events` takes `profileId` and the same `events` array. Pozy forwards each event to every saved destination, exactly as `POST /v1/ads/conversions` would. Pass `destinations` in the body to override the saved list for one request. ### Read the stream `GET /v1/events?profileId=...` lists stored events, newest first. Personal data is never returned: `hasEmail`, `hasPhone` and `hasExternalId` only say whether an event carried them. - One destination failing never blocks the others. Each reports its own result. - Pozy stores email, phone and `externalId` only as SHA-256 hashes, and keeps only the origin, path and attribution parameters of `sourceUrl`. - Events sent with `testCode` are forwarded but not stored. ## Test mode `testCode` at the request root lets you check an integration without polluting live data. | Network | What `testCode` does | | --- | --- | | Meta | The batch shows in the Test Events tab of Events Manager. | | Pinterest | Pinterest validates the batch and records nothing. | | Reddit | Sent as Reddit's test id, visible in its Events Manager. | Remove `testCode` for production traffic. An event sent with it is not counted on Pinterest. ## Consent The root `consent` object takes `adUserData` and `adPersonalization`, each `GRANTED` or `DENIED`. Split batches by consent state rather than by network. | Network | Effect of `DENIED` | | --- | --- | | Meta | Events are flagged for Limited Data Use, with the location derived from the event. | | Google Ads | Sent as Google's EEA and UK consent signals. Omit `consent` outside those regions if you have no data. | | Pinterest | Each event is marked opt-out. | | Reddit | User identifiers are dropped; only the click id is sent. | ## Match quality `GET /v1/ads/conversions/quality?accountId=...&destinationId=...` reads the network's own scoring of your server events, without opening its dashboard. - **Meta:** Event Match Quality per event name, `compositeScore` from 0 to 10, coverage per identifier in `matchKeys`, and how well server events overlap the pixel. Web events only. - **Pinterest:** Event Quality Score over the last 14 days, per event name, with `matchKeys` and pixel overlap. There is no composite score. ## Batching and limits Pozy splits large batches for you. What happens when one event is bad depends on the network: | Network | Chunk size | One bad event | | --- | --- | --- | | Meta | 1,000 | Fails its chunk | | Google Ads | 2,000 | Listed in `failures[]` | | LinkedIn | 5,000 | Fails its chunk | | TikTok | 1,000 | The network accepts or rejects a batch whole | | Pinterest | Per event | Reported on its own | | Reddit | 1,000 | Reported on its own; rejected chunks show `REDDIT_REJECTED` | | ChatGPT Ads | 1,000 | Fails its chunk | Google can take up to 3 hours to show an uploaded conversion. Reddit accepts events up to 7 days old. ## Permissions and reconnects Some networks grant the conversions permission separately. A connection made before that permission existed has to go through the connect flow again. | Network | Symptom | Fix | | --- | --- | --- | | LinkedIn | `403 linkedin_reconnect_required` | Reconnect LinkedIn Ads so the grant includes conversions. | | TikTok | Calls refused for the Offline Events permission | Reconnect TikTok Ads. | | Reddit | `403 reconnect_required` | Reconnect Reddit Ads to grant `adsconversions`. | | Pinterest | `403` | Give the connected user a Business Access role on the ad account. | | ChatGPT Ads | `422 tracking_tag_required` | Create the pixel first. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/conversions/quality`](https://pozy.ai/docs/api/getConversionsQuality) | Get Event Match Quality | | [`POST /v1/ads/conversions`](https://pozy.ai/docs/api/sendConversions) | Send conversion events | | [`POST /v1/ads/conversions/adjustments`](https://pozy.ai/docs/api/adjustConversions) | Adjust uploaded conversions | | [`GET /v1/ads/conversions/actions`](https://pozy.ai/docs/api/listConversionActions) | List conversion actions | | [`POST /v1/ads/conversions/actions`](https://pozy.ai/docs/api/createConversionAction) | Create website conversion action | | [`GET /v1/ads/conversions/goals`](https://pozy.ai/docs/api/listAdConversionGoals) | List account conversion goals | | [`PATCH /v1/ads/conversions/goals`](https://pozy.ai/docs/api/updateAdConversionGoals) | Update account conversion goals | | [`PATCH /v1/ads/conversions/actions/{actionId}`](https://pozy.ai/docs/api/updateConversionAction) | Update a conversion action's settings | | [`GET /v1/ads/conversions/custom-goals`](https://pozy.ai/docs/api/listCustomConversionGoals) | List custom conversion goals | | [`POST /v1/ads/conversions/custom-goals`](https://pozy.ai/docs/api/createCustomConversionGoal) | Create a custom conversion goal | | [`PATCH /v1/ads/conversions/custom-goals/{goalId}`](https://pozy.ai/docs/api/updateCustomConversionGoal) | Update a custom conversion goal | | [`DELETE /v1/ads/conversions/custom-goals/{goalId}`](https://pozy.ai/docs/api/removeCustomConversionGoal) | Remove a custom conversion goal | | [`GET /v1/events`](https://pozy.ai/docs/api/listEvents) | List stored events | | [`POST /v1/events`](https://pozy.ai/docs/api/sendEvents) | Send events to every destination | | [`GET /v1/events/destinations`](https://pozy.ai/docs/api/listEventDestinations) | List event destinations | | [`POST /v1/events/destinations`](https://pozy.ai/docs/api/createEventDestination) | Add an event destination | | [`DELETE /v1/events/destinations/{destinationId}`](https://pozy.ai/docs/api/deleteEventDestination) | Remove an event destination | | [`GET /v1/accounts/{accountId}/conversion-destinations`](https://pozy.ai/docs/api/listConversionDestinations) | List conversion destinations | | [`POST /v1/accounts/{accountId}/conversion-destinations`](https://pozy.ai/docs/api/createConversionDestination) | Create a conversion destination | | [`GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}`](https://pozy.ai/docs/api/getConversionDestination) | Get a conversion destination | | [`PATCH /v1/accounts/{accountId}/conversion-destinations/{destinationId}`](https://pozy.ai/docs/api/updateConversionDestination) | Update a conversion destination | | [`DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}`](https://pozy.ai/docs/api/deleteConversionDestination) | Delete a conversion destination | | [`GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations`](https://pozy.ai/docs/api/listConversionAssociations) | List associated campaigns | | [`POST /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations`](https://pozy.ai/docs/api/addConversionAssociations) | Associate campaigns | | [`DELETE /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations`](https://pozy.ai/docs/api/removeConversionAssociations) | Remove associated campaigns | | [`GET /v1/accounts/{accountId}/conversion-destinations/{destinationId}/metrics`](https://pozy.ai/docs/api/getConversionMetrics) | Get attribution metrics | Meta custom conversions (rules over pixel events) live on the ad account: `GET` and `POST /v1/accounts/{accountId}/custom-conversions`. --- # Pixels and tracking tags Create, install and verify each ad network's pixel or site tag through one API, and set the click-URL parameters (UTMs) that ads carry to your site. A pixel is the small script an ad network runs on your site to see what people do after they click: page views, add-to-carts, purchases. Conversion campaigns, website audiences and server-side conversions all point at one. Pozy manages every network's pixel through the same endpoints under `/v1/accounts/{accountId}/tracking-tags`, and installs it on a connected Shopify, WordPress, Wix or Webflow site with one call, without editing a theme. This page also covers the other kind of "tracking tag": the parameters an ad adds to its click URL, such as `utm_source`. Those are set per ad. Jump to [Click-URL parameters](#click-url-parameters). ## What each network calls it `accountId` in the path is always the **ads connection** (`metaads`, `tiktokads`, `googleads`, `linkedinads`, `pinterestads`, `xads`, `openaiads`), never a posting account. | Network | Name | `kind` | `tagId` | `siteTagId` (what the site loads) | | --- | --- | --- | --- | --- | | Meta | Meta Pixel | `pixel` | Numeric pixel id | Same as `tagId` | | TikTok | TikTok Pixel | `pixel` | Numeric pixel id | Alphanumeric pixel code | | Google Ads | Google tag | `tag` | The customer id | `AW-...` | | LinkedIn | Insight Tag | `insight_tag` | Partner id | Same as `tagId` | | Pinterest | Pinterest tag | `tag` | Numeric tag id | Same as `tagId` | | X | X Pixel | `pixel` | The X ad account id | The pixel id | | ChatGPT Ads | Pixel | `pixel` | `cds_...` | The pixel's site id | Reddit pixels are created in Reddit Ads Manager and are not managed here. Reddit's server-side events still go through [Conversions](https://pozy.ai/docs/ads/conversions). ## Set up a pixel end to end ### Look for one that already exists Most ad accounts already have a pixel, and most creates cannot be undone. List first. ```bash cURL curl "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags?adAccountId=act_1234567890" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags?adAccountId=act_1234567890", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.tags); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"adAccountId": "act_1234567890"}, ) print(resp.json()) ``` `GET .../tracking-tags/{tagId}` returns one tag with its install `code` and its conversion `events`. `status` is `active` or `inactive`. ### Create one if you need to `POST /v1/accounts/{accountId}/tracking-tags` takes `adAccountId` and `name`. ```bash curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "act_1234567890", "name": "Storefront pixel" }' ``` Two optional fields are network-specific: `defaultEventType` on ChatGPT Ads (creates the conversion event a conversions campaign needs) and `automaticMatchingFields` on Pinterest (enhanced match, only settable at creation). Other networks return `400` if you send them. > **Creates are not reversible:** > > No network lets you delete a pixel, and on Meta, TikTok, Pinterest and ChatGPT Ads every create makes a new one. Never retry a create automatically. After a timeout or a `502`, list the tags and check before sending it again. ### Install it on your site `POST .../tracking-tags/{tagId}/install` with the `storeAccountId` of a connected store or site: ```bash curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags/1729525464415281/install \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "storeAccountId": "66c3d4e5f6a7b8c9d0e1f2a3" }' ``` The response says whether it is `installed`, with `siteTagId` and every Pozy-installed tag on that site in `tags`. Installs are idempotent, so repeating one changes nothing. `GET` on the same path reads the status and `DELETE` removes it, both with `?storeAccountId=`. ### Check it fires Open your site, then read `GET .../tracking-tags/{tagId}`. Where the network reports it, `lastFiredTime` (Unix seconds) and `installed` turn on after the first event. For more detail, `GET .../tracking-tags/{tagId}/stats` returns the network's own numbers (see the table below), and Meta also offers `GET .../diagnostics` with a list of health checks and links to fix them. ## How installs work per site | Site | How the tag goes on | | --- | --- | | [Shopify](https://pozy.ai/docs/platforms/shopify) | One Pozy web pixel per store fires each network's tag from Shopify's customer events (page view, product view, add to cart, checkout, payment info, purchase). Browser events carry an event id so server-side conversions can be matched. | | [WordPress](https://pozy.ai/docs/platforms/wordpress) | One Custom HTML widget per tag, read back to confirm the script survived. `verifyHomepage` (default `true`) checks the home page. If the site cannot run scripts, the call returns `422 tracking_tag_install_blocked`. | | [Wix](https://pozy.ai/docs/platforms/wix) | One script per site loads each tag's base code. | | [Webflow](https://pozy.ai/docs/platforms/webflow) | One script in the site's custom code, live after the next publish. `publishRequired: true` in the response means you still need to publish. | On WordPress, Wix and Webflow the tag loads its base code and reports page views. Shopify maps store events to each network's conversion events, so create those events first (next section). ## Conversion events A tag's conversion events are what a site fires and what campaigns optimize for. Manage them on `.../tracking-tags/{tagId}/events`: list with `GET`, create with `POST`, change with `PATCH .../events/{eventId}`, retire with `DELETE`. The body takes a `name` and either the network's own `type` or a neutral `siteEvent`. `siteEvent` is the store vocabulary the installs use: `page_view`, `view_content`, `add_to_cart`, `search`, `initiate_checkout`, `add_payment_info`, `purchase`. Networks that store more also take `defaultValue` and `currency`, `clickWindowDays` and `viewWindowDays`, `urlContains` and `enabled`. ```bash curl -X POST https://api.pozy.ai/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/tracking-tags/7412345678901234567/events \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Purchase", "siteEvent": "purchase" }' ``` | Network | What an event is | Create | Update | Delete | | --- | --- | --- | --- | --- | | Meta | A custom conversion | Idempotent by name | Name, value | Archives | | TikTok | A pixel event | Not idempotent; may come back `pending` with no id for a few hours | Name, value, currency | Not available | | Google Ads | A website conversion action | Duplicate names refused | Yes, including windows, value, counting and primary | Archives; `enabled: true` restores | | LinkedIn | A conversion rule | Not idempotent | Yes | Disables; `enabled: true` turns it back on | | Pinterest | An advertiser-defined event (up to 15 per ad account) | Keyed by name | Type only | Stops tracking | | X | A web event | Not idempotent | Name, type, windows | Permanent | | ChatGPT Ads | An event setting (fixed 30-day window) | Not idempotent | Not available | Not available | ## What each network supports | Operation | Meta | TikTok | Google | LinkedIn | Pinterest | X | ChatGPT | | --- | --- | --- | --- | --- | --- | --- | --- | | Create a tag | Not idempotent | Not idempotent | Idempotent (turns on conversion tracking) | Idempotent, one per ad account | Not idempotent | One per ad account (`409`) | One per connection (`409`) | | Update the tag | Name, advanced matching, first-party cookies, data use | Name, automatic matching, first-party cookies | `autoTagging` only | First-party tracking only | No | First-party cookies only | No | | `lastFiredTime`, `installed` | Yes | No | No | Yes | Yes | Yes | No | | Stats | Aggregated counts, many `aggregation` options | Per event, 7 days by default, 30 at most | Per conversion action, 30 days by default | Last fire per domain and rule | Last fired, Event Quality Score | Last tracked per event, no counts | Events from the last 15 minutes | | Diagnostics | Yes | No | No | No | No | No | No | | Share with other ad accounts | Business Manager pixels | Business Center pixels | No | `USE_ONLY` access | No | No | No | | Users and partner businesses | Yes | No | No | No | No | No | No | | Delete the tag | No | No | No | No | No | No | No | An operation a network does not offer returns `501 platform_not_supported`, and the message names where to do it instead (usually the network's own ads manager). `PATCH .../tracking-tags/{tagId}` takes any of `name`, `enableAutomaticMatching`, `automaticMatchingFields`, `firstPartyCookieStatus`, `enableFirstPartyCookies`, `dataUseSetting` and `autoTagging`. A field the network does not store returns `400` listing the ones it does. ### Sharing `.../tracking-tags/{tagId}/shared-accounts` lists (`GET`), adds (`POST` with `adAccountId`) and removes (`DELETE ?adAccountId=`) the other ad accounts that can use a tag. On Meta the pixel must belong to a Business Manager; on TikTok it must be a Business Center asset; on LinkedIn the grant is use-only and the tag cannot be left with no ad account. ### Users and partners (Meta) `GET` and `POST .../tracking-tags/{tagId}/users` list people with access and assign one with `{ userId, tasks }`. Tasks are `AA_ANALYZE`, `ADVERTISE`, `ANALYZE`, `EDIT` and `UPLOAD`. `GET .../partners` lists partner businesses, read only. ## Network notes - **Meta.** A new pixel accepts server events right away, even before the script is on the site. A pixel created on an ad account outside a Business Manager cannot be shared. - **TikTok.** Names are up to 40 characters with no emoji. A connection made before TikTok's pixel permission answers every call with `403`; reconnect TikTok Ads. - **Google Ads.** Each customer has exactly one Google tag. The "create" call turns conversion tracking on and returns the existing tag if it is already on. Its events are the account's website conversion actions. - **LinkedIn.** One Insight Tag per ad account. It has no name, so Pozy shows it as `Insight Tag `. - **Pinterest.** The connected user needs a Business Access role on the ad account. Stats take no time range. - **X.** One pixel per ad account. Events X creates with the pixel are hidden and cannot be changed. See [X Ads](https://pozy.ai/docs/ads/x-ads#x-pixel). - **ChatGPT Ads.** Creating the pixel also creates its Conversions API key. Some ad accounts need pixel management enabled by their ChatGPT Ads representative (`422 feature_not_available`). See [ChatGPT Ads](https://pozy.ai/docs/ads/chatgpt-ads#the-pixel). ## Click-URL parameters Click-URL parameters are added to the landing page URL when someone clicks an ad, so your analytics can tell where the visit came from. They live on each ad, not on the pixel. ### Set them at launch `tracking.urlTags` on `POST /v1/ads/create` and `POST /v1/ads/boost` takes `{ key, value }` pairs. `tracking.pixelId` sets the pixel the ad measures with. ```json { "tracking": { "pixelId": "1729525464415281", "urlTags": [ { "key": "utm_source", "value": "meta" }, { "key": "utm_campaign", "value": "{{campaign.id}}" }, { "key": "utm_content", "value": "{{ad.id}}" } ] } } ``` Network macros pass through unescaped so the network expands them at click time; everything else is percent-encoded. Meta's macros look like `{{ad.id}}` and `{{placement}}`. ChatGPT Ads uses `{campaign_id}`, `{ad_group_id}`, `{ad_id}` and `{oppref}`. > **Note:** > > Tracking sits on the ad, not the ad set, so it is not inherited. When you add a new ad to an existing ad set, send `tracking` again. ### Read or change them later `GET /v1/ads/{adId}/tracking-tags` reads what an ad carries and `PATCH` changes it. What you can set depends on the network: | Network | Fields | Level | Notes | | --- | --- | --- | --- | | Meta | `urlTags` | Creative | Meta creatives cannot be edited, so Pozy rebuilds the creative with the same copy and image and points the ad at it. The new creative goes through review again. For creatives Pozy cannot copy, also send `creative` with `headline`, `body`, `callToAction`, `linkUrl` and `imageUrl`. | | Google Ads | `trackingUrlTemplate` (must contain `{lpurl}`), `finalUrlSuffix` | Campaign | Pass any ad in the campaign. An empty string clears a field. | | LinkedIn | `dynamicValueParameters` (keys mapped to `CAMPAIGN_ID`, `CAMPAIGN_NAME`, `CAMPAIGN_GROUP_ID`, `CAMPAIGN_GROUP_NAME`, `CREATIVE_ID`, `ACCOUNT_ID`, `ACCOUNT_NAME`), `customValueParameters` (static values) | Campaign | Applies to every creative without review. Conversation, message and lead form ads do not support it. | | TikTok, X, Pinterest | None | | Returns `405`. Put parameters in `linkUrl` instead. | ```bash curl -X PATCH https://api.pozy.ai/v1/ads/66f0a1b2c3d4e5f6a7b8c9d0/tracking-tags \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "finalUrlSuffix": "utm_source=google&utm_medium=cpc&utm_campaign={campaignid}" }' ``` ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `400` naming `accountId` | A posting account, or a network without tags (Reddit) | Use the ads connection's `accountId`. | | `400` listing supported fields | A field this network does not store | Send only the fields the message lists. | | `403 insufficient_permissions` | The network refused the token, or the connection predates a permission | Reconnect the ads connection. | | `404 tracking_tag_not_found` | The tag is not on this connection or ad account | List the tags for a current id. | | `409` | One tag per account already exists (X, ChatGPT Ads), or the store needs attention | Use the existing tag, or reconnect the store. | | `422 tracking_tag_install_blocked` | WordPress cannot run the script | Check the site's script and widget settings. | | `501 platform_not_supported` | The network has no such operation | Use the network's own ads manager, as the message says. | | `405` on ad tracking tags | The network has no click-URL parameters | Put them in the ad's link. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/{adId}/tracking-tags`](https://pozy.ai/docs/api/getAdTrackingTags) | Get ad tracking tags | | [`PATCH /v1/ads/{adId}/tracking-tags`](https://pozy.ai/docs/api/updateAdTrackingTags) | Set ad tracking tags | | [`GET /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/listTrackingTags) | List tracking tags | | [`POST /v1/accounts/{accountId}/tracking-tags`](https://pozy.ai/docs/api/createTrackingTag) | Create a tracking tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}`](https://pozy.ai/docs/api/getTrackingTag) | Get a tracking tag | | [`PATCH /v1/accounts/{accountId}/tracking-tags/{tagId}`](https://pozy.ai/docs/api/updateTrackingTag) | Update a tracking tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts`](https://pozy.ai/docs/api/listTrackingTagSharedAccounts) | List accounts it is shared with | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts`](https://pozy.ai/docs/api/addTrackingTagSharedAccount) | Share with an ad account | | [`DELETE /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts`](https://pozy.ai/docs/api/removeTrackingTagSharedAccount) | Stop sharing with an account | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/install`](https://pozy.ai/docs/api/getTrackingTagStoreInstall) | Get store install status | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/install`](https://pozy.ai/docs/api/installTrackingTagOnStore) | Install on a Shopify, WordPress, Wix or Webflow site | | [`DELETE /v1/accounts/{accountId}/tracking-tags/{tagId}/install`](https://pozy.ai/docs/api/removeTrackingTagFromStore) | Remove from a Shopify, WordPress, Wix or Webflow site | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/events`](https://pozy.ai/docs/api/listTrackingTagEvents) | List conversion events | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/events`](https://pozy.ai/docs/api/createTrackingTagEvent) | Create a conversion event | | [`PATCH /v1/accounts/{accountId}/tracking-tags/{tagId}/events/{eventId}`](https://pozy.ai/docs/api/updateTrackingTagEvent) | Update a conversion event | | [`DELETE /v1/accounts/{accountId}/tracking-tags/{tagId}/events/{eventId}`](https://pozy.ai/docs/api/deleteTrackingTagEvent) | Delete a conversion event | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/users`](https://pozy.ai/docs/api/listTrackingTagUsers) | List tag users | | [`POST /v1/accounts/{accountId}/tracking-tags/{tagId}/users`](https://pozy.ai/docs/api/assignTrackingTagUser) | Assign a user to a tag | | [`DELETE /v1/accounts/{accountId}/tracking-tags/{tagId}/users/{userId}`](https://pozy.ai/docs/api/removeTrackingTagUser) | Remove a user from a tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/partners`](https://pozy.ai/docs/api/listTrackingTagPartners) | List partner businesses of a tag | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/diagnostics`](https://pozy.ai/docs/api/getTrackingTagDiagnostics) | Get tag diagnostics | | [`GET /v1/accounts/{accountId}/tracking-tags/{tagId}/stats`](https://pozy.ai/docs/api/getTrackingTagStats) | Get aggregated event stats | --- # Automated ad rules Pause, reactivate or rebudget campaigns, ad sets and ads on a schedule when their metrics cross a threshold, or get a webhook when they do. An ad rule watches one ad account for you. On a schedule, it checks every campaign, ad set or ad against a set of conditions ("cost per conversion above 25 over the last 7 days") and acts on each one that matches: pause it, turn it back on, change its daily budget, or send you an `ad_rule.triggered` webhook. Rules run on Meta, Google, TikTok, LinkedIn, Pinterest, X and Reddit. ## Create a rule safely ### Begin with a template `GET /v1/ads/rules/templates` gives you finished rules to copy, for jobs such as cutting off ad sets that lose money, giving winners more budget, pausing ads that bring no conversions, spotting tired creative and warning you when spend jumps. A template is already a full rule body. Its `platforms` field names the ad networks it suits; `null` means every network. Take one, set `name`, `accountId` and `adAccountId`, and adjust the thresholds. Money amounts are in the ad account's currency. ### Create it switched off This rule checks every hour and pauses any ad set that spent at least 50 in the last 7 days at a cost per conversion above 25: ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/rules \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Pause expensive ad sets", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "level": "adset", "conditions": { "all": [ { "metric": "cpa", "window": "last_7d", "op": ">", "value": 25 }, { "metric": "spend", "window": "last_7d", "op": ">=", "value": 50 } ] }, "actions": [{ "type": "pause", "maxChangesPerDay": 10 }, { "type": "notify" }], "schedule": { "everyMinutes": 60 }, "cooldownMinutes": 1440, "enabled": false }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/rules", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Pause expensive ad sets", accountId: "66b2e19d8c3f5a7e9d0b1c2d", adAccountId: "act_1234567890", level: "adset", conditions: { all: [ { metric: "cpa", window: "last_7d", op: ">", value: 25 }, { metric: "spend", window: "last_7d", op: ">=", value: 50 }, ], }, actions: [{ type: "pause", maxChangesPerDay: 10 }, { type: "notify" }], schedule: { everyMinutes: 60 }, cooldownMinutes: 1440, enabled: false, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.rule.id); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/rules", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "name": "Pause expensive ad sets", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "adAccountId": "act_1234567890", "level": "adset", "conditions": {"all": [ {"metric": "cpa", "window": "last_7d", "op": ">", "value": 25}, {"metric": "spend", "window": "last_7d", "op": ">=", "value": 50}, ]}, "actions": [{"type": "pause", "maxChangesPerDay": 10}, {"type": "notify"}], "schedule": {"everyMinutes": 60}, "cooldownMinutes": 1440, "enabled": False, }, ) print(resp.json()["rule"]["id"]) ``` The response (`201`) is the rule, with `lastRunAt` and `nextRunAt`. `enabled` defaults to `true`, so send `false` explicitly while you test. ### Preview against today's numbers `POST /v1/ads/rules/{ruleId}/preview` evaluates the conditions on current metrics and acts on nothing. It returns every object with `matched`, the actual value of each condition, and the time zone and currency used. ```bash curl -X POST https://api.pozy.ai/v1/ads/rules/66f9a0b1c2d3e4f5a6b7c8d9/preview \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "evaluatedAt": "2026-10-30T09:00:00.000Z", "today": "2026-10-30", "timezone": "America/New_York", "timezoneAssumed": false, "currency": "USD", "matched": 1, "objects": [ { "id": "120210000000000001", "name": "Retargeting, 7 day", "campaignId": "120210000000000000", "matched": true, "conditions": [ { "metric": "cpa", "actual": 31.4 }, { "metric": "spend", "actual": 188.2 } ] } ] } ``` ### Turn it on ```bash curl -X PATCH https://api.pozy.ai/v1/ads/rules/66f9a0b1c2d3e4f5a6b7c8d9 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## Rule fields | Field | Meaning | | --- | --- | | `accountId` | Your ads connection (`metaads`, `googleads`, `tiktokads`, `linkedinads`, `pinterestads`, `xads` or `redditads`), not an account you post with. | | `adAccountId` | Which ad account of that connection to watch. Take it from `GET /v1/ads/accounts?accountId=...`; it has to be one the connection syncs. | | `level` | `campaign`, `adset` or `ad`. Each object at that level is checked on its own. | | `campaignIds` | Optional. Only these campaigns, or the ad sets and ads inside them. | | `conditions.all` | 1 to 10 conditions. All must hold for an object to match. | | `actions` | What to do with each match (below). | | `schedule.everyMinutes` | `60`, `180`, `360`, `720` or `1440`. | | `cooldownMinutes` | How long an object rests after the rule acts on it. Default `1440` (a day), up to 43,200. | | `enabled` | Whether it runs. Default `true`. | ### Conditions Each condition checks one metric, measured over one time window, against a threshold: ```json { "metric": "roas", "window": "last_3d", "op": "<", "value": 1.5 } ``` - **Metrics:** `spend`, `impressions`, `clicks`, `conversions`, `cpc`, `cpm`, `cpa`, `roas`, `reach`, `frequency`, and `ctr` as a percentage. - **Windows:** `today`, `yesterday`, `last_3d`, `last_7d`, `last_14d`, `last_30d`. A day is a day in the ad account's time zone, and the multi-day windows count today. - **Operators:** `>`, `>=`, `<`, `<=`. - `reach` and `frequency` need de-duplicated reach, which only Meta and TikTok provide. On other networks they return `400`. - A rate with nothing to divide by never matches. A `cpa` condition skips objects with no conversions, so pair it with a `spend` floor as in the example. ### Actions | `type` | What it does | | --- | --- | | `notify` | Sends the `ad_rule.triggered` webhook with the objects that matched. Subscribe an endpoint first (see [Webhooks](https://pozy.ai/docs/webhooks)), or a notify-only rule only leaves run history. | | `pause` | Pauses the object. | | `activate` | Turns back on what this rule paused, once the cooldown has passed. It never touches objects you paused yourself. | | `adjust_budget` | Changes the daily budget. `direction` is `increase` or `decrease`; `percent` is up to 100 up or 90 down; `limit` is the ceiling for increases and the floor for decreases. Not on `level: "ad"`. | Combine `notify` with no more than one changing action: `pause`, `activate` or `adjust_budget`. Those three accept `maxChangesPerDay`, from 1 to 100, which caps how many objects the rule may change in a rolling 24 hours, counting all its runs. Objects past the cap are skipped with `daily_limit_reached` and picked up on a later run. A rule that changes objects must also have a `cooldownMinutes` no shorter than its schedule. ## What a rule did `GET /v1/ads/rules/{ruleId}/runs` shows a rule's runs over the past 31 days, latest first. For the workspace as a whole, `GET /v1/ads/rules/activity` lists each change that any rule made. | `outcome` | What happened | | --- | --- | | `triggered` | The rule acted on the objects that matched, listed in `actedObjectIds`. | | `no_match` | No object passed all the conditions. | | `cooldown` | Objects matched, but all were resting. | | `failed` | Metrics could not be read, every action failed, or a notify-only rule could not queue its webhook. `error` says why. | Each run's `evaluation.actions[]` records `status` (`done`, `skipped` or `failed`) per object, with a `reason` on skips and `previousDailyBudget` and `newDailyBudget` on budget changes. `evaluation.notification.status` is `done` once the webhook is queued. ## The webhook `ad_rule.triggered` fires when a run of a rule with a `notify` action acts on at least one object. - `objects[]` lists only the objects the run acted on. Objects in cooldown are left out, and a run where every match is resting sends nothing. - `objects[].conditions` holds every condition again, with `actual` set to the object's real value. - `objects[].actions` records what the rule changed on the object; a rule that only notifies leaves it empty. - `run.today`, `run.timezone` and `run.currency` tell you which calendar day and currency the numbers used. `run.timezoneAssumed` is `true` when the ad account has no known time zone and UTC was used. The full payload is in the [events reference](https://pozy.ai/docs/webhooks/events#ad-rule-triggered). ## How rules behave - **Metrics lag.** Rules read the metrics Pozy syncs from each network, which can trail the network's own dashboard. - **Batch size.** A run changes 20 objects at most; any further matches are handled by the next run. - **Failed actions** get no cooldown, so the next run retries them. - **Your own pauses.** `activate` only restores what the rule paused. If you turn an object on and off again between two runs, the rule cannot tell your pause from its own. - **Google budgets** live on campaigns, so `adjust_budget` on Google needs `level: "campaign"`. - **Ads access.** If the workspace loses ads access, every rule pauses until it returns. - **Free allowance.** Past the free allowance of live ads without a payment method, reactivations and budget increases are skipped with `ads_allowance_exceeded`. Pauses and decreases keep working. - **Disabled rules** stop running until you set `enabled: true` again. `DELETE /v1/ads/rules/{ruleId}` removes a rule for good. ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `403 ad_account_not_selected` | The ad account is not one the connection syncs | Add it with `adAccountIds` on the connect call, which replaces the selection. | | `400 invalid_field_value` on `cooldownMinutes` | The rule changes objects and its cooldown is shorter than its schedule | Set `cooldownMinutes` to `schedule.everyMinutes` or more. | | `400 invalid_field_value` on `actions` | A budget change at ad level, or on ad sets in Google | Switch to `level: "campaign"`, or `adset` on networks other than Google. | | `400 invalid_field_value` on `conditions` | `reach` or `frequency` on a network other than Meta or TikTok | Pick a different metric. | | `403 ads_allowance_exceeded` | A reactivating or budget-raising rule past the free live-ads allowance | Add a payment method. | | `404 resource_not_found` | No such rule in this workspace | Check the `ruleId`. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/rules`](https://pozy.ai/docs/api/listAdRules) | List ad rules | | [`POST /v1/ads/rules`](https://pozy.ai/docs/api/createAdRule) | Create an ad rule | | [`GET /v1/ads/rules/templates`](https://pozy.ai/docs/api/listAdRuleTemplates) | List ready-made ad rules | | [`GET /v1/ads/rules/activity`](https://pozy.ai/docs/api/listAdRuleActivity) | List every change ad rules made | | [`GET /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/getAdRule) | Get an ad rule | | [`PATCH /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/updateAdRule) | Update an ad rule | | [`DELETE /v1/ads/rules/{ruleId}`](https://pozy.ai/docs/api/deleteAdRule) | Delete an ad rule | | [`POST /v1/ads/rules/{ruleId}/preview`](https://pozy.ai/docs/api/previewAdRule) | Preview an ad rule | | [`GET /v1/ads/rules/{ruleId}/runs`](https://pozy.ai/docs/api/listAdRuleRuns) | List an ad rule's runs | ## Webhook events | Event | When it fires | | --- | --- | | [`ad_rule.triggered`](https://pozy.ai/docs/webhooks/events#ad-rule-triggered) | Ad rule triggered. | --- # Lead forms Collect leads inside Meta and LinkedIn ads with native forms, receive them by webhook or by polling, and route them into your CRM. A lead form lets someone send you their details without leaving the ad. The network prefills what it knows (name, email, phone), the person taps submit, and you get the lead. Pozy creates forms on Meta (Facebook and Instagram) and LinkedIn with the same endpoints, delivers Meta leads to your webhook the moment they arrive, and lets you read leads from both networks in one list. | | Meta | LinkedIn | | --- | --- | --- | | Form lives on | The Facebook Page | The Company Page that owns the ad account | | `accountId` | A connected Facebook account, or a `metaads` connection with a Page selected | The `linkedinads` connection | | Prefilled questions | `EMAIL`, `PHONE`, `FULL_NAME`, `FIRST_NAME`, `LAST_NAME` | Not available through the API | | Custom questions | Text and multiple choice | `text` and `multipleChoice` | | New leads | `lead.received` webhook, in real time | Poll `GET /v1/ads/leads` | | Lead history | Stored by Pozy | Read live from LinkedIn, kept 90 days by LinkedIn | | Test leads | Yes | No | Reddit retired its native lead forms in September 2026. On Reddit, `lead_generation` sends people to your own page (see [Reddit Ads](https://pozy.ai/docs/ads/reddit-ads)). ## Collect leads on Meta > **Accept the lead ads terms first:** > > Each Facebook Page must accept Meta's Lead Generation terms once, at `https://www.facebook.com/ads/leadgen/tos/`, before lead ads can run. Until it does, ad creation fails with Meta subcode `1815089`. Creating forms and reading leads work before that. ### Create the form `POST /v1/ads/lead-forms` takes `accountId`, `name`, `privacyPolicyUrl` and the form content in `platformSpecificData`. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/lead-forms \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "name": "Kitchen remodel quote", "privacyPolicyUrl": "https://example.com/privacy", "platformSpecificData": { "questions": [ { "type": "FULL_NAME" }, { "type": "EMAIL" }, { "type": "PHONE" }, { "type": "CUSTOM", "key": "budget", "label": "What is your budget?", "options": [ { "key": "under_10k", "value": "Under $10k" }, { "key": "over_10k", "value": "$10k or more" } ] } ], "thankYouTitle": "Thanks, we will call you within a day", "thankYouButtonText": "See our work", "thankYouButtonType": "VIEW_WEBSITE", "thankYouWebsiteUrl": "https://example.com/projects" } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/lead-forms", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2d", name: "Kitchen remodel quote", privacyPolicyUrl: "https://example.com/privacy", platformSpecificData: { questions: [ { type: "FULL_NAME" }, { type: "EMAIL" }, { type: "PHONE" }, { type: "CUSTOM", key: "budget", label: "What is your budget?", options: [ { key: "under_10k", value: "Under $10k" }, { key: "over_10k", value: "$10k or more" }, ], }, ], thankYouTitle: "Thanks, we will call you within a day", thankYouButtonText: "See our work", thankYouButtonType: "VIEW_WEBSITE", thankYouWebsiteUrl: "https://example.com/projects", }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.form.id); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/lead-forms", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "name": "Kitchen remodel quote", "privacyPolicyUrl": "https://example.com/privacy", "platformSpecificData": { "questions": [ {"type": "FULL_NAME"}, {"type": "EMAIL"}, {"type": "PHONE"}, { "type": "CUSTOM", "key": "budget", "label": "What is your budget?", "options": [ {"key": "under_10k", "value": "Under $10k"}, {"key": "over_10k", "value": "$10k or more"}, ], }, ], "thankYouTitle": "Thanks, we will call you within a day", "thankYouButtonText": "See our work", "thankYouButtonType": "VIEW_WEBSITE", "thankYouWebsiteUrl": "https://example.com/projects", }, }, ) print(resp.json()["form"]["id"]) ``` ```json { "status": "success", "form": { "id": "1029384756102938", "name": "Kitchen remodel quote" } } ``` Prefilled question types make their own label and key, so send only `{ "type": "..." }`. A `CUSTOM` question needs `key` and `label`, plus `options` for a choice question. Creating a form is not idempotent. A retry makes a second form, so do not retry it automatically. ### Attach it to an ad Send `goal: "lead_generation"` and `leadGenFormId` on `POST /v1/ads/create` or `POST /v1/ads/boost`. No `linkUrl` is needed: the form is the destination, and Pozy sets the ad set's lead optimization and promoted Page for you. ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "adAccountId": "act_1234567890", "name": "Remodel quote, lead ad", "goal": "lead_generation", "leadGenFormId": "1029384756102938", "budgetAmount": 20, "budgetType": "daily", "headline": "Get a free remodel quote", "body": "Tell us about your kitchen. We reply within a day.", "callToAction": "SIGN_UP", "imageUrl": "https://cdn.example.com/kitchen.jpg" } ``` Every ad in a lead ad set needs its own `leadGenFormId`. Instagram lead ads use the Page's form. The full create request is in [Meta Ads](https://pozy.ai/docs/ads/meta-ads). ### Send a test lead `POST /v1/ads/lead-forms/{formId}/test-leads` puts a fake lead on the form so you can check your webhook and field mapping before spending. ```bash curl -X POST https://api.pozy.ai/v1/ads/lead-forms/1029384756102938/test-leads \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "fieldData": [{ "name": "email", "values": ["test@example.com"] }] }' ``` Meta allows one pending test lead per form. Delete it with `DELETE /v1/ads/lead-forms/{formId}/test-leads` before creating another. ### Receive leads Subscribe a [webhook](https://pozy.ai/docs/webhooks) endpoint to `lead.received`. Pozy forwards each Meta lead as soon as Meta reports it. ### Form options Everything below goes inside `platformSpecificData`. Unknown keys return `400`. | Field | What it does | | --- | --- | | `questions` | Prefilled and custom questions, in order | | `thankYouTitle`, `thankYouBody`, `thankYouButtonText`, `thankYouButtonType`, `thankYouWebsiteUrl` | The screen after submit | | `businessPhoneNumber`, `countryCode` | Required together when `thankYouButtonType` is `CALL_BUSINESS`, and only then. The number is international with `+` (`+12025550123`); `countryCode` is ISO 3166-1 alpha-2. | | `contextCard` | An intro screen: `title`, `content`, `style` (`LIST_STYLE` or `PARAGRAPH_STYLE`), `buttonText`, and `coverPhoto` (a direct public JPEG or PNG URL, up to 5 MB) | | `customDisclaimer` | Your own legal text with `title`, `body` and consent `checkboxes` | | `isOptimizedForQuality` | Adds a review step before submit, for fewer but better leads | | `isPhoneSmsVerifyEnabled` | Verifies the phone number by text message | | `locale`, `followUpActionUrl`, `privacyPolicyLinkText`, `questionPageCustomHeadline` | Language, follow-up link and wording | | `blockDisplayForNonTargetedViewer` | Hides the form from people outside the ad's targeting | `MESSAGE_BUSINESS` is not accepted as a thank-you button. The older top-level form fields still work when `platformSpecificData` is absent, but mixing the two shapes in one request returns `400`. ## Collect leads on LinkedIn LinkedIn forms use the same endpoints with a LinkedIn-shaped `platformSpecificData`, picked by the `linkedinads` `accountId`. They belong to the Company Page of the ad account, so `adAccountId` goes inside the form. ```bash curl -X POST https://api.pozy.ai/v1/ads/lead-forms \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2f", "name": "Q3 benchmark report", "privacyPolicyUrl": "https://example.com/privacy", "platformSpecificData": { "adAccountId": "517258773", "headline": "Get the 2027 benchmark report", "description": "Tell us where to send it.", "state": "PUBLISHED", "questions": [ { "kind": "text", "name": "company", "question": "Company name", "maxResponseLength": 120 }, { "kind": "multipleChoice", "name": "team_size", "question": "Team size", "choices": [ { "id": 1, "text": "1 to 10" }, { "id": 2, "text": "11 to 50" }, { "id": 3, "text": "51 or more" } ] } ], "consents": [ { "description": "I agree to be contacted about this report", "required": true } ] } }' ``` | Field | Rule | | --- | --- | | `headline` | Up to 255 characters | | `description` | Up to 1,500 characters | | `state` | `DRAFT` (default) or `PUBLISHED` | | `questions` | `kind`, `name` and `question` on each. `multipleChoice` also needs `choices`. `maxResponseLength` defaults to 300. | | `consents` | `{ description, required }` checkboxes | | `locale` | `{ country, language }` | Pass `form.id` as `leadGenFormId` on a LinkedIn ad with `goal: "lead_generation"` (see [LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads)). Creating a form is not idempotent. LinkedIn has no lead webhook. Read leads with `GET /v1/ads/leads?accountId=...&adAccountId=...`, optionally with `formId`, on your own schedule. LinkedIn keeps responses for 90 days, so copy them out regularly. > **Reading LinkedIn leads needs one more permission:** > > If `GET /v1/ads/leads` returns `400 ads_connection_required` for a LinkedIn account, the connection predates the permission that reads lead responses. Run the LinkedIn connect flow again to grant it. Creating, listing and archiving forms work without it. ## Read leads `GET /v1/ads/leads` is one list across every form and both networks, newest first. It is the easiest feed for a CRM sync. ```bash curl "https://api.pozy.ai/v1/ads/leads?accountId=66b2e19d8c3f5a7e9d0b1c2d&status=new&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "status": "success", "leads": [ { "id": "66f1a2b3c4d5e6f7a8b9c0d1", "leadgenId": "5566778899001122", "formId": "1029384756102938", "formName": "Kitchen remodel quote", "adId": "120260000000000000", "campaignId": "120250000000000000", "isOrganic": false, "createdTime": "2027-02-14T10:31:05.000Z", "fields": { "full_name": "Jamie Rivera", "email": "jamie@example.com", "phone_number": "+14155550199", "budget": "over_10k" }, "consents": [], "status": "new" } ], "pagination": { "hasMore": false, "cursor": null } } ``` | Filter | Meaning | | --- | --- | | `formId`, `campaignId`, `accountId`, `accountIds`, `adAccountId` | Scope the list. LinkedIn needs `adAccountId`. | | `platform` | `meta` or `linkedin` | | `status` | One of the statuses below | | `q` | Free-text search, up to 200 characters | | `since` | Unix **seconds**. A millisecond value returns `400`. | | `limit`, `cursor` | Up to 100 per page; pass `pagination.cursor` back for the next page | `GET /v1/ads/lead-forms/{formId}/leads` reads one Meta form. `GET /v1/ads/lead-forms` lists forms and `DELETE /v1/ads/lead-forms/{formId}` archives one; neither network has a hard delete. `fields` maps each question key to the answer. In the webhook, multiple-choice answers are the option key (`over_10k`); from LinkedIn they are the choice text. ## Track a lead to a sale Each stored Meta lead has a status you can move along your funnel: `new`, `contacted`, `unreachable`, `qualified`, `converted`, `disqualified`. `PATCH /v1/ads/leads/{id}` sets it, with an optional `value` and `currency` when it converts. ```bash curl -X PATCH https://api.pozy.ai/v1/ads/leads/66f1a2b3c4d5e6f7a8b9c0d1 \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "converted", "value": 18500, "currency": "USD" }' ``` `{id}` is Pozy's lead id, or Meta's `leadgenId` with `accountId` in the body. `GET /v1/ads/leads/counts` returns how many leads sit in each status, every status present with `0` when empty, for a badge or a pipeline view. To teach Meta which leads turn into customers, send the outcome back as a conversion with `user.leadId` set to Meta's lead id. See [Conversions](https://pozy.ai/docs/ads/conversions). ## Route leads to a CRM The `lead.received` payload carries the lead and the account it came from: ```json { "id": "evt_8a1f4c2e9b7d3a60", "event": "lead.received", "timestamp": "2027-02-14T10:31:07.000Z", "profiles": [{ "id": "66b2e19d8c3f5a7e9d0b1c2a", "externalId": "client_482" }], "lead": { "id": "66f1a2b3c4d5e6f7a8b9c0d1", "leadgenId": "5566778899001122", "formId": "1029384756102938", "formName": "Kitchen remodel quote", "adId": "120260000000000000", "campaignId": "120250000000000000", "fields": { "full_name": "Jamie Rivera", "email": "jamie@example.com", "budget": "over_10k" }, "consents": [{ "checkboxKey": "call_consent", "isChecked": true }], "isOrganic": false, "createdAt": "2027-02-14T10:31:05.000Z" }, "account": { "id": "66b2e19d8c3f5a7e9d0b1c2d", "platform": "facebook", "profileId": "66b2e19d8c3f5a7e9d0b1c2a" } } ``` A handler that verifies the signature, drops repeats and upserts a contact: ```ts import crypto from "node:crypto"; import express from "express"; const app = express(); const seen = new Set(); // use a database table in production app.post("/webhooks/pozy", express.raw({ type: "application/json" }), async (req, res) => { const expected = crypto.createHmac("sha256", process.env.POZY_WEBHOOK_SECRET!).update(req.body).digest("hex"); const given = req.header("X-Pozy-Signature") ?? ""; if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) { return res.status(401).end(); } const event = JSON.parse(req.body.toString("utf8")); res.status(200).end(); // answer fast, work after if (event.event !== "lead.received" || seen.has(event.lead.leadgenId)) return; seen.add(event.lead.leadgenId); const tenant = event.profiles?.[0]?.externalId; // your customer id, if you build a platform await upsertCrmContact(tenant, { name: event.lead.fields.full_name, email: event.lead.fields.email, phone: event.lead.fields.phone_number, source: `${event.lead.formName} (ad ${event.lead.adId ?? "organic"})`, callConsent: event.lead.consents.find((c: { checkboxKey: string }) => c.checkboxKey === "call_consent")?.isChecked ?? false, }); }); declare function upsertCrmContact(tenant: string | undefined, contact: Record): Promise; ``` - Dedupe on `lead.leadgenId` (Meta's lead id) or on the event `id`. Retries reuse the same event id. - `lead.adId` is `null` for organic and test leads. - `consents` lists every custom disclaimer checkbox. Use it to record call and text consent. - `account.profileId` and `profiles[].externalId` tell you which of your customers the lead belongs to. See [Route webhooks to tenants](https://pozy.ai/docs/build/route-webhooks). - For LinkedIn, run the same mapping on a schedule over `GET /v1/ads/leads?platform=linkedin`. ## When leads stop arriving | Symptom | Cause | Fix | | --- | --- | --- | | No `lead.received` from a Page that used to send them | The Page lost its lead subscription while other events still arrive | Read `GET /v1/accounts/{accountId}/webhook-subscription`. If it shows `leadgen: false`, call `POST` on the same path to subscribe the Page again. | | No leads from a Facebook account connected before ads were enabled | The connection lacks the lead retrieval permission | Reconnect the Facebook account. | | Ad creation fails with subcode `1815089` | The Page has not accepted the Lead Generation terms | Accept them once at `https://www.facebook.com/ads/leadgen/tos/`. | | `422` on form create, Meta code `3` | Meta refused the form without naming a field | If you set `isPhoneSmsVerifyEnabled`, drop it and set it in Meta's form builder instead. | | `400 ads_connection_required` on LinkedIn leads | Missing lead reading permission | Reconnect LinkedIn Ads. | | `400` on `since` | A millisecond timestamp | Divide by 1,000. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/leads`](https://pozy.ai/docs/api/listLeads) | List submitted leads | | [`GET /v1/ads/leads/counts`](https://pozy.ai/docs/api/countLeads) | Count leads by status | | [`PATCH /v1/ads/leads/{id}`](https://pozy.ai/docs/api/updateLead) | Update a lead's status | | [`GET /v1/ads/lead-forms`](https://pozy.ai/docs/api/listLeadForms) | List lead forms | | [`POST /v1/ads/lead-forms`](https://pozy.ai/docs/api/createLeadForm) | Create a lead form | | [`GET /v1/ads/lead-forms/{formId}`](https://pozy.ai/docs/api/getLeadForm) | Get a lead form | | [`DELETE /v1/ads/lead-forms/{formId}`](https://pozy.ai/docs/api/archiveLeadForm) | Archive a lead form | | [`GET /v1/ads/lead-forms/{formId}/leads`](https://pozy.ai/docs/api/listFormLeads) | List leads for a single form | | [`POST /v1/ads/lead-forms/{formId}/test-leads`](https://pozy.ai/docs/api/createTestLead) | Create a test lead | | [`DELETE /v1/ads/lead-forms/{formId}/test-leads`](https://pozy.ai/docs/api/deleteTestLead) | Delete a test lead | ## Webhook events | Event | When it fires | | --- | --- | | [`lead.received`](https://pozy.ai/docs/webhooks/events#lead-received) | Lead received. | --- # Product catalogs and catalog ads Build and fill a Meta product catalog, group products into sets, and run catalog ads that show each person the products they are most likely to buy. A catalog ad is one ad that covers many products. Instead of uploading an image per item, you point the ad at a product set and Meta renders a card for each product, then picks which ones each person sees based on what they browsed. Pozy manages the catalog behind it too: create a catalog, add products one at a time, in bulk or from a feed file, and define product sets. Catalogs and catalog ads run on Meta (Facebook and Instagram). The same catalog can also power Facebook and Instagram Shops and a WhatsApp Business catalog. ## Run a catalog ad You need a Meta ads connection, an ad account with a pixel, and a catalog with at least one product set. If the catalog already exists in Meta Commerce Manager, start at step 1. To build one from scratch, see [Build a catalog](#build-a-catalog) first. ### Find the catalog `GET /v1/ads/catalogs` lists the catalogs the ad account's business owns or has been shared. ```bash curl "https://api.pozy.ai/v1/ads/catalogs?accountId=66b2e19d8c3f5a7e9d0b1c2e&adAccountId=act_1234567890" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "businessId": "1092837465019283", "catalogs": [ { "id": "1003405825408877", "name": "Home store", "vertical": "commerce", "productCount": 412, "businessId": "1092837465019283" } ] } ``` The business is resolved from the first of these you give: `businessId`, the owner of `adAccountId`, the owner of a WhatsApp Business Account (when `accountId` is a WhatsApp connection), or the only business the Meta login can see. ### Pick the product set The ad promotes a product set, not the whole catalog. List the sets with `GET /v1/ads/catalogs/{catalogId}/product-sets`: ```json { "productSets": [{ "id": "1003260032095191", "name": "All products", "productCount": 412 }] } ``` ### Create the ad Send `goal: "catalog_sales"` with all three `promotedObject` fields. There is no `imageUrl` or `video`: Meta builds the visuals from each product, and your copy becomes a template filled per item. ```bash cURL curl -X POST https://api.pozy.ai/v1/ads/create \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: home-store-catalog-q1" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "adAccountId": "act_1234567890", "name": "Home store, catalog", "goal": "catalog_sales", "budgetAmount": 40, "budgetType": "daily", "headline": "{{product.name}}", "body": "Free shipping on orders over $50", "description": "{{product.price}}", "callToAction": "SHOP_NOW", "linkUrl": "https://shop.example.com", "countries": ["US"], "optimizationGoal": "OFFSITE_CONVERSIONS", "promotedObject": { "productSetId": "1003260032095191", "pixelId": "1729525464415281", "customEventType": "PURCHASE" } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "home-store-catalog-q1", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "66b2e19d8c3f5a7e9d0b1c2e", adAccountId: "act_1234567890", name: "Home store, catalog", goal: "catalog_sales", budgetAmount: 40, budgetType: "daily", headline: "{{product.name}}", body: "Free shipping on orders over $50", description: "{{product.price}}", callToAction: "SHOP_NOW", linkUrl: "https://shop.example.com", countries: ["US"], optimizationGoal: "OFFSITE_CONVERSIONS", promotedObject: { productSetId: "1003260032095191", pixelId: "1729525464415281", customEventType: "PURCHASE", }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.ad._id, data.ad.platformObjective); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/ads/create", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "home-store-catalog-q1", }, json={ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "adAccountId": "act_1234567890", "name": "Home store, catalog", "goal": "catalog_sales", "budgetAmount": 40, "budgetType": "daily", "headline": "{{product.name}}", "body": "Free shipping on orders over $50", "description": "{{product.price}}", "callToAction": "SHOP_NOW", "linkUrl": "https://shop.example.com", "countries": ["US"], "optimizationGoal": "OFFSITE_CONVERSIONS", "promotedObject": { "productSetId": "1003260032095191", "pixelId": "1729525464415281", "customEventType": "PURCHASE", }, }, ) print(resp.json()["ad"]["status"]) ``` The response is a sales campaign (`platformObjective: "OUTCOME_SALES"`), an ad set bound to the product set, and a catalog template ad, starting in `pending_review`. > **Set the optimization goal:** > > Without `optimizationGoal: "OFFSITE_CONVERSIONS"`, the ad set optimizes for link clicks, which buys clicks for a campaign you meant to drive purchases. Send it, and the ad set optimizes toward the `customEventType` in `promotedObject`. ### Template tags Copy fields accept Meta's catalog tags, replaced per product: `{{product.name}}`, `{{product.price}}`, and on a vehicle catalog `{{vehicle.make}}`, `{{vehicle.model}}`, `{{vehicle.year}}` and `{{vehicle.price}}`. `catalogFormat` picks the layout: `single_image`, `carousel_images_multi_items`, `carousel_images_single_item` or `carousel_slideshows`. ### Rules for catalog ads - `promotedObject.productSetId`, `pixelId` and `customEventType` are all required. Meta's "Promoted Object is Required" error usually means the pixel is missing, not the set. - Pozy checks the product set before creating anything. Passing the catalog id where the set id belongs, or a set the login cannot read, returns `400` naming `promotedObject.productSetId`. - A catalog ad is one creative. It cannot be combined with `creatives[]`, `adSetId`, `dynamicCreative` or `placementAssets`. - Targeting works as on any campaign. Broad targeting is fine here because Meta matches products to people. - Instagram placements need an Instagram identity. When the Page has no linked Instagram account, Pozy uses the Page-backed one. - Creating the ad from a known product set id does not need catalog permissions. Reading and editing the catalog does (below). ## Build a catalog ### Permission Listing, creating and editing catalogs needs Meta's `catalog_management` permission on the Meta login. Not every login has it. When the connection you use cannot manage catalogs, Meta answers `403` (code 100). Pass `catalogAccountId` naming another connected `facebook`, `instagram` or `metaads` account whose Meta login does. ### Create the catalog ```bash curl -X POST https://api.pozy.ai/v1/ads/catalogs \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "adAccountId": "act_1234567890", "name": "Home store", "vertical": "commerce" }' ``` `vertical` defaults to `commerce`. Others include `vehicles`, `hotels`, `flights`, `destinations`, `home_listings`, `local_service_businesses` and `services`. Deleting a catalog (`DELETE /v1/ads/catalogs/{catalogId}`) removes every product and takes it off any ads and WhatsApp numbers that use it. ### Add products Products are keyed by `retailerId`, your SKU. It is your handle for later lookups and updates, and it cannot change. | Way in | Call | Good for | | --- | --- | --- | | One product | `POST /v1/ads/catalogs/{catalogId}/products` | Adding items as they go live | | Bulk | `POST /v1/ads/catalogs/{catalogId}/products/batch` | Up to 5,000 creates, updates or deletes in one request | | Feed file | `POST /v1/ads/catalogs/{catalogId}/feeds` | Keeping a large catalog in sync from a CSV, TSV or XML file | ```bash curl -X POST https://api.pozy.ai/v1/ads/catalogs/1003405825408877/products/batch \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "requests": [ { "method": "CREATE", "product": { "retailerId": "sku-981", "name": "Linen throw", "url": "https://shop.example.com/p/linen-throw", "imageUrl": "https://cdn.example.com/linen-throw.jpg", "price": 64.99, "currency": "USD", "availability": "in stock", "condition": "new", "brand": "Acme Home" } }, { "method": "UPDATE", "product": { "retailerId": "sku-772", "salePrice": 39.00, "inventory": 12 } }, { "method": "DELETE", "product": { "retailerId": "sku-105" } } ] }' ``` The bulk call answers `202` with `handles`. Meta processes them in the background; poll `GET /v1/ads/catalogs/{catalogId}/batches/{handle}` for the status and per-product `errors`. A `CREATE` needs `name`, `url`, `imageUrl`, `price` and `currency`. Product fields include `description`, `additionalImageUrls`, `salePrice` with `salePriceStartDate` and `salePriceEndDate`, `availability`, `condition`, `brand`, `category`, `googleProductCategory`, `gtin`, `mpn`, `inventory`, `visibility` (`published` or `staging`), `color`, `size`, `gender`, `material`, `pattern` and `customLabel0` to `customLabel4`. `GET .../products?retailerId=sku-981` looks one product up by SKU; prices come back formatted by Meta, such as `"$64.99"`. ### Keep it in sync with a feed A feed pulls a product file from a URL. With `schedule`, Meta fetches it on a cadence; without it, trigger each fetch yourself. ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "name": "Nightly product export", "schedule": { "interval": "DAILY", "url": "https://shop.example.com/exports/products.csv", "hour": 3 } } ``` `interval` is `HOURLY`, `DAILY` or `WEEKLY` (with `dayOfWeek`). `POST .../feeds/{feedId}/uploads` with a `url` fetches a file right away, and `GET .../feeds/{feedId}/uploads` shows each import with `itemsDetected`, `itemsDeleted`, `errorCount` and `warningCount`. If your store is connected to Pozy, you can read its products from the [commerce endpoints](https://pozy.ai/docs/commerce/products) and push them in with the bulk call, or point a feed at the store's own product export. ### Group products into sets A product set is a saved filter over the catalog. It uses Meta's filter syntax: ```json { "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "name": "Acme brand, in stock", "filter": { "and": [ { "brand": { "i_contains": "acme" } }, { "availability": { "eq": "in stock" } } ] } } ``` A filter on SKUs looks like `{ "retailer_id": { "is_any": ["sku-981", "sku-772"] } }`. Update a set's name or filter with `PUT .../product-sets/{productSetId}` and remove it with `DELETE`. Pass the set's `id`, never the catalog's, as `promotedObject.productSetId`. ## Sell in WhatsApp too The same catalog can back a WhatsApp Business number. Link it with `POST /v1/whatsapp/catalogs`; the catalog must belong to the same business as the WhatsApp Business Account, and `catalogAccountId` must name a Meta login with `catalog_management`. See [WhatsApp](https://pozy.ai/docs/whatsapp). ## Errors you will see | Status and code | Cause | Fix | | --- | --- | --- | | `403` (Meta code 100) | The Meta login lacks `catalog_management` | Pass `catalogAccountId` for a login that has it. | | `400` naming `promotedObject.productSetId` | The catalog id was sent as the set id, or the set is unreadable | Re-read ids from the product sets list. | | `400` naming `promotedObject.productCatalogId` | The catalog does not contain that set | Send the catalog that owns the set, or leave it out. | | `400` naming `creatives` | `creatives[]` with `goal: "catalog_sales"` | Send one creative. | | Meta "Promoted Object is Required" | The pixel is missing | Add `promotedObject.pixelId`. | | Errors in a batch status | Products Meta refused, listed by `retailerId` | Fix those products and resend them. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/catalogs`](https://pozy.ai/docs/api/listAdCatalogs) | List Meta product catalogs | | [`POST /v1/ads/catalogs`](https://pozy.ai/docs/api/createAdCatalog) | Create a Meta product catalog | | [`GET /v1/ads/catalogs/{catalogId}`](https://pozy.ai/docs/api/getAdCatalog) | Get a product catalog | | [`DELETE /v1/ads/catalogs/{catalogId}`](https://pozy.ai/docs/api/deleteAdCatalog) | Delete a product catalog | | [`GET /v1/ads/catalogs/{catalogId}/products`](https://pozy.ai/docs/api/listAdCatalogProducts) | List a catalog's products | | [`POST /v1/ads/catalogs/{catalogId}/products`](https://pozy.ai/docs/api/createAdCatalogProduct) | Add a product to a catalog | | [`POST /v1/ads/catalogs/{catalogId}/products/batch`](https://pozy.ai/docs/api/batchAdCatalogProducts) | Create, update or delete products in bulk | | [`GET /v1/ads/catalogs/{catalogId}/batches/{handle}`](https://pozy.ai/docs/api/getAdCatalogBatch) | Get a bulk request's status | | [`GET /v1/ads/catalogs/{catalogId}/products/{productId}`](https://pozy.ai/docs/api/getAdCatalogProduct) | Get a product | | [`PUT /v1/ads/catalogs/{catalogId}/products/{productId}`](https://pozy.ai/docs/api/updateAdCatalogProduct) | Update a product | | [`DELETE /v1/ads/catalogs/{catalogId}/products/{productId}`](https://pozy.ai/docs/api/deleteAdCatalogProduct) | Delete a product | | [`GET /v1/ads/catalogs/{catalogId}/feeds`](https://pozy.ai/docs/api/listAdCatalogFeeds) | List a catalog's product feeds | | [`POST /v1/ads/catalogs/{catalogId}/feeds`](https://pozy.ai/docs/api/createAdCatalogFeed) | Create a product feed | | [`GET /v1/ads/catalogs/{catalogId}/feeds/{feedId}/uploads`](https://pozy.ai/docs/api/listAdCatalogFeedUploads) | List a feed's uploads | | [`POST /v1/ads/catalogs/{catalogId}/feeds/{feedId}/uploads`](https://pozy.ai/docs/api/createAdCatalogFeedUpload) | Fetch a feed file now | | [`GET /v1/ads/catalogs/{catalogId}/product-sets`](https://pozy.ai/docs/api/listAdCatalogProductSets) | List a catalog's product sets | | [`POST /v1/ads/catalogs/{catalogId}/product-sets`](https://pozy.ai/docs/api/createAdCatalogProductSet) | Create a product set | | [`PUT /v1/ads/catalogs/{catalogId}/product-sets/{productSetId}`](https://pozy.ai/docs/api/updateAdCatalogProductSet) | Update a product set | | [`DELETE /v1/ads/catalogs/{catalogId}/product-sets/{productSetId}`](https://pozy.ai/docs/api/deleteAdCatalogProductSet) | Delete a product set | --- # Ads reporting Read spend, delivery and conversion metrics across every ad network, from rolled-up dashboards to breakdowns, live network queries and sync health events. Pozy syncs the performance of every ad on every connected ad account, whether it was created through Pozy or in the network's own ads manager. Most reporting reads those synced numbers and costs nothing on the network's side. When you need something the sync does not hold, such as an age split or a custom Google query, Pozy asks the network live. ## Pick the right call | You want | Call | Source | | --- | --- | --- | | A dashboard: spend, CPC, CPM, conversions and ROAS per campaign, ad set and ad | `GET /v1/ads/tree` | Synced, rolled up per level | | A flat list of ads, or one ad with its metrics | `GET /v1/ads`, `GET /v1/ads/{adId}` | Synced | | One row per day for an account | `GET /v1/ads/timeline` | Synced | | One ad or campaign over a range, with optional breakdowns | `GET /v1/ads/{adId}/analytics`, `GET /v1/ads/campaigns/{campaignId}/analytics` | Synced, breakdowns live | | Any field, breakdown or filter the network supports | `GET /v1/ads/insights` | Live (Meta, Google Ads, TikTok) | | The same over a long range or many accounts | `POST /v1/ads/insights/reports` | A background job (Meta) | Network-specific reports live on their pages: Google's search terms and keyword planner in [Google Ads](https://pozy.ai/docs/ads/google-ads), LinkedIn firmographics in [LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads). ## Build a dashboard `GET /v1/ads/tree` returns campaigns with their ad sets and ads nested inside, each with a `metrics` object for the date range. Filter and sort it like a report. ```bash cURL curl "https://api.pozy.ai/v1/ads/tree?accountId=66b2e19d8c3f5a7e9d0b1c2e&fromDate=2027-01-01&toDate=2027-01-31&hasDelivery=true&sort=spend_desc&compare=previous" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/ads/tree?accountId=66b2e19d8c3f5a7e9d0b1c2e&fromDate=2027-01-01&toDate=2027-01-31&hasDelivery=true&sort=spend_desc&compare=previous", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.campaigns) console.log(c.campaignName, c.metrics?.spend, c.metrics?.roas); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/ads/tree", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "fromDate": "2027-01-01", "toDate": "2027-01-31", "hasDelivery": "true", "sort": "spend_desc", "compare": "previous", }, ) for c in resp.json()["campaigns"]: print(c["campaignName"], c.get("metrics", {}).get("spend")) ``` Useful filters on the tree: `platform`, `status`, `adAccountId`, `profileId`, `campaignId`, `search`, `minSpend`, `hasDelivery`, `updatedSince` and `source` (`pozy` for ads created through Pozy, `all` by default). `compare=previous` adds the same metrics for the period just before, for "vs last month" views. `timeIncrement=1` with `dailyLevel` (`campaign`, `adset` or `ad`) adds a daily series at that level. For an account-level chart, `GET /v1/ads/timeline?accountId=...` returns one row per day with spend, impressions, reach, clicks, engagement, conversions and the derived rates. ## One ad over time `GET /v1/ads/{adId}/analytics` returns a `summary` for the range and a `daily` series. ```bash curl "https://api.pozy.ai/v1/ads/66f0a1b2c3d4e5f6a7b8c9d0/analytics?fromDate=2027-01-04&toDate=2027-01-31&breakdowns=age,gender" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "backfillPending": false, "ad": { "id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Spring sale", "platform": "facebook", "status": "active", "currency": "USD" }, "analytics": { "summary": { "spend": 412.6, "impressions": 98100, "reach": 61200, "clicks": 1530, "ctr": 1.56, "cpc": 0.27, "cpm": 4.21, "conversions": 38, "costPerConversion": 10.86, "purchaseValue": 2140.0, "roas": 5.19 }, "daily": [{ "date": "2027-01-04", "spend": 14.9, "impressions": 3400, "clicks": 51 }], "breakdowns": { "age": [{ "value": "25-34", "spend": 168.2, "impressions": 40100 }] } } } ``` ### Breakdowns `breakdowns` takes a comma-separated list. Each dimension is one live call to the network, so ask only for what you show. | Network | Per ad | Per campaign | Dimensions | | --- | --- | --- | --- | | Meta | Yes | Yes | `age`, `gender`, `country`, `region`, `publisher_platform`, `device_platform` | | TikTok | Yes | No | `gender`, `age`, `country_code`, `platform`, `ac`, `language` | | LinkedIn | Yes | Yes (the campaign group) | `job_title`, `job_function`, `seniority`, `industry`, `company`, `company_size`, `country`, `region` | | Others | No | No | | A dimension no network defines returns `400` listing the valid ones. A dimension from another network (say `age` on a LinkedIn ad) is accepted and dropped, and the response has no `breakdowns` object. LinkedIn returns firmographics for the whole range only, with no daily split, lags 12 to 24 hours and hides segments with fewer than 3 events. ## Metrics Every `metrics` and `summary` object uses the same names on every network. Money is in the ad account's currency, in whole units. | Metric | Meaning | | --- | --- | | `spend`, `impressions`, `clicks` | The base counters | | `reach` | People reached. On most networks it is de-duplicated within a day only, so never add it across days. | | `ctr`, `cpc`, `cpm` | Derived from the counters. `ctr` is a percentage. | | `engagement` | Interactions the network counts as engagement | | `conversions`, `allConversions`, `costPerConversion` | The network's primary conversions, every conversion, and spend per primary conversion | | `actions`, `actionValues`, `costPerAction` | Counts, values and costs per action type | | `purchaseValue`, `roas` | Purchase revenue and `purchaseValue / spend`. Rolled up from sums, never averaged. | | `outboundClicks`, `inlineLinkClicks`, `uniqueClicks` and their CTRs | Click detail, where the network reports it | | `videoPlayActions`, `video2SecWatchedActions`, `video6SecWatchedActions`, `videoP25WatchedActions` to `videoP100WatchedActions`, `videoThruplayWatchedActions`, `videoAvgTimeWatchedActions`, `costPerThruplay` | Video delivery | | `funnel` | Landing page views, content views, searches, cart adds, checkouts, payment info, purchases, leads, registrations, app installs and messaging conversations | | `engagementBreakdown` | Post and page engagement, reactions, comments and more | On Meta, the same purchase often shows under several action keys (a pixel key, an `omni_` key and a plain one). Prefer the `offsite_conversion.fb_pixel_*` keys, or read `conversions` and `purchaseValue`, which already pick one. ### What each network reports | Network | Synced metrics | | --- | --- | | Meta | Everything above, including reach and ROAS | | Google Ads | Spend, delivery and conversions; raw GAQL for everything else | | TikTok | Spend, delivery, conversions and video metrics; de-duplicated reach through live queries | | LinkedIn | Spend and delivery; conversions per conversion rule | | Pinterest, X | Spend, impressions, clicks and rates. `reach` reads `0`. | | Reddit, ChatGPT Ads | Impressions, clicks and spend only, with CTR, CPC and CPM. Everything else reads `0`. | ## Date ranges and history - Without `fromDate` and `toDate`, ranges default to the last 90 days. The longest range is 730 days. - When an ad account connects, Pozy imports its last 90 days. Synced summaries hold nothing before that. - On Meta, asking for older days answers `202` with `backfillPending: true` and the part already covered, while Pozy imports the rest (up to 730 days back) in the background. Repeat the request until it returns `200`. Other networks never return `202`. - Breakdowns and live queries read the network directly, so they are not limited by the import. - Days follow the ad account's time zone. ### Currencies Pozy never converts currencies. Each campaign node and timeline row carries `currency`. When one connection's ad accounts bill in different currencies, the timeline returns one row per currency per day. Do not add spend across them. ## Attribution Each network counts a conversion against an ad within its own windows, so numbers differ between networks for the same sale. | Network | Where the window is set | | --- | --- | | Meta | `attributionSpec` on create: `eventType` (`CLICK_THROUGH`, `VIEW_THROUGH`, `ENGAGED_VIDEO_VIEW`) with `windowDays` of 1, 7 or 28. Live queries take `actionAttributionWindows` and `useUnifiedAttributionSetting`. | | Google Ads | Per conversion action, plus which actions count toward bidding through conversion goals. See [Conversions](https://pozy.ai/docs/ads/conversions). | | LinkedIn | Per conversion rule: click and view windows of 1, 7, 30, 90 or 365 days. `GET .../conversion-destinations/{id}/metrics` reads conversions per rule. | | X | Per conversion event, with click and view windows up to 90 days. | | Reddit | Fixed 7-day click, 1-day view for conversion goals. | | ChatGPT Ads | Fixed 30 days. | ## Live queries `GET /v1/ads/insights` passes your query to the network and returns its rows as they come. The connection's platform decides which parameters apply, and validation is the network's: an unknown field returns `400` with the network's message. | Network | Send | Paging | | --- | --- | --- | | Meta | `objectId` (any insights node: ad account, campaign, ad set, ad), `level`, `fields`, `breakdowns`, `actionBreakdowns`, `filtering`, `datePreset` or `fromDate`/`toDate`, `timeIncrement` | `limit` and `after` | | Google Ads | `query`: any read-only GAQL `SELECT`. `adAccountId` when the connection has several customers. | 10,000 rows per page; pass `paging.nextPageToken` as `pageToken` | | TikTok | `adAccountId`, `dataLevel`, `dimensions` (1 to 4), `metrics`, `fromDate`/`toDate` (at most 365 days), optional `filtering` and `reportType` | `page` and `pageSize` (up to 1,000) | ```bash curl -G https://api.pozy.ai/v1/ads/insights \ -H "Authorization: Bearer $POZY_API_KEY" \ --data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c2f" \ --data-urlencode "query=SELECT campaign.name, metrics.cost_micros, metrics.clicks FROM campaign WHERE segments.date DURING LAST_7_DAYS" ``` Google returns its own units, so `cost_micros` divides by 1,000,000, and counters arrive as strings. Selecting `segments.date` needs a finite date filter. ### Reach across several campaigns on TikTok Adding the reach of two campaigns counts a person who saw both twice. To count each person once, filter the set and group by country instead of by campaign: ```text dataLevel=AUCTION_CAMPAIGN&dimensions=country_code&metrics=reach,impressions,frequency &fromDate=2027-01-01&toDate=2027-01-31 &filtering=[{"field":"campaign_ids","operator":"IN","value":["1876574798182050","1876575504573650"]}] ``` Reach is then per country; adding countries together double counts people reached in both. Filter at the same level as `dataLevel` or a coarser one. ### Long reports on Meta For long ranges or many accounts, submit a background job with the same fields as a live Meta query: ```bash curl -X POST https://api.pozy.ai/v1/ads/insights/reports \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "objectId": "act_1234567890", "level": "ad", "fields": "ad_name,spend,impressions,actions", "fromDate": "2026-01-01", "toDate": "2026-12-31", "timeIncrement": "monthly" }' ``` It answers `202` with a `reportRunId`. Poll `GET /v1/ads/insights/reports/{reportRunId}`: it reports `status` and `percentCompletion` while it runs, and once `status` is `Job Completed` it returns the rows, paged with `limit` and `after`. ## Know when numbers are stale Subscribe to these [webhook](https://pozy.ai/docs/webhooks) events instead of polling sync state: | Event | When it fires | | --- | --- | | `account.ads.initial_sync_completed` | Once per connection, after ad account discovery and the 90-day import. `sync.status` says whether it fully or partly succeeded; on failure, `sync.errorCategory` is `token_invalid`, `permission_denied`, `no_ad_accounts`, `rate_limited`, `discovery_failed` or `unknown`. | | `account.ads.sync_failed` | Once when an ad account has had no successful sync for 24 hours, or every live ad in it hit the retry limit. Flag its numbers as stale. `sync.errorCategory` is `ad_account_not_listed`, `sync_error` or `stale`. | | `account.ads.sync_recovered` | Once when that ad account syncs again. | | `ad_account.status_changed` | An ad account became `active`, `restricted`, `disabled` or `closed` (Meta, Google, TikTok, X). | | `ad.review.approved`, `ad.review.rejected` | A network's review decision on an ad you already had, within one sync cycle. | | `ad.delivery_issue` | An ad stopped delivering for a network reason (Meta, TikTok, LinkedIn). | | `campaign.budget.depleted` | A TikTok campaign ran out of budget. | A failed initial sync is not final: running the ads connect flow again queues the import again. Reconnecting fixes `token_invalid`, `permission_denied` and `rate_limited`; `no_ad_accounts` needs the person to have an ad account first. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree | | [`GET /v1/ads/timeline`](https://pozy.ai/docs/api/getAdsTimeline) | Get daily account metrics | | [`GET /v1/ads`](https://pozy.ai/docs/api/listAds) | List ads | | [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details | | Endpoint | What it does | | --- | --- | | [`GET /v1/ads/search-terms`](https://pozy.ai/docs/api/getAdsSearchTerms) | Google Ads search terms report | | [`GET /v1/ads/local-services/leads`](https://pozy.ai/docs/api/listLocalServicesLeads) | Google Local Services Ads leads | | [`GET /v1/ads/local-services/leads/{leadId}/conversations`](https://pozy.ai/docs/api/listLocalServicesLeadConversations) | List lead conversations | | [`GET /v1/ads/campaigns/{campaignId}/analytics`](https://pozy.ai/docs/api/getCampaignAnalytics) | Get campaign analytics | | [`POST /v1/ads/keywords/ideas`](https://pozy.ai/docs/api/generateKeywordIdeas) | Generate keyword ideas | | [`POST /v1/ads/keywords/historical-metrics`](https://pozy.ai/docs/api/generateKeywordHistoricalMetrics) | Get historical keyword metrics | | [`GET /v1/ads/insights`](https://pozy.ai/docs/api/queryAdInsights) | Flexible live insights query | | [`POST /v1/ads/insights/reports`](https://pozy.ai/docs/api/createAdInsightsReport) | Submit async insights report | | [`GET /v1/ads/insights/reports/{reportRunId}`](https://pozy.ai/docs/api/getAdInsightsReport) | Poll an async insights report run | | [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics | | [`GET /v1/ads/tiktok-smart-plus-materials`](https://pozy.ai/docs/api/getTikTokSmartPlusMaterialReport) | Per-creative performance inside TikTok Smart+ ads | ## Webhook events | Event | When it fires | | --- | --- | | [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. | | [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. | | [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. | | [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. | | [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. | | [`ad.delivery_issue`](https://pozy.ai/docs/webhooks/events#ad-delivery-issue) | Ad delivery issue. | | [`campaign.budget.depleted`](https://pozy.ai/docs/webhooks/events#campaign-budget-depleted) | Campaign budget depleted. | | [`ad_account.status_changed`](https://pozy.ai/docs/webhooks/events#ad-account-status-changed) | Ad account status changed. | --- # Connect WhatsApp Connect a WhatsApp Business number to a profile with Embedded Signup or a Meta token, set its business profile and watch its quality and limits. A WhatsApp account in Pozy is one WhatsApp Business number attached to a [profile](https://pozy.ai/docs/build/profiles). Once it is connected you send templates, replies, flows and broadcasts from it, take calls on it and read every conversation in the [inbox](https://pozy.ai/docs/inbox/messages). This page gets a number connected and ready to send. ## Before you start - A Meta Business account. If it has no WhatsApp Business Account (WABA) yet, Meta creates one during signup. - A phone number for WhatsApp. Either [buy one through Pozy](https://pozy.ai/docs/whatsapp/phone-numbers) or bring one you already own. - A Pozy profile id and an API key. A personal WhatsApp account cannot be connected. Only WhatsApp Business numbers on Meta's Cloud API can. Two WhatsApp rules apply to everything you send: | Rule | What it means for you | | --- | --- | | 24-hour customer service window | After a customer messages you, you can reply with anything for 24 hours. Outside that window the first message must be an approved [template](https://pozy.ai/docs/whatsapp/templates). | | Messaging limit tier | Meta caps how many unique people a number can start conversations with per day. A new number starts at `TIER_250` (250 per day). Meta raises the tier as the number builds history with good quality. | ## Pick the kind of number Where the number comes from decides what else it can do. | Number | How it connects | Calls and SMS on the same number | | --- | --- | --- | | Bought or ported through Pozy | Embedded Signup. The number is already verified and shows as **Verified** in Meta's picker. | Yes | | Your own number on your own carrier | Embedded Signup, or a Meta System User token | No | A number bought through Pozy is pinned to the profile you bought it for. Connecting it from another profile returns `409`. Move it first with [`PATCH /v1/whatsapp/phone-numbers/{id}/profile`](https://pozy.ai/docs/api/moveWhatsAppNumberToProfile). A profile can hold several WhatsApp numbers, and each one becomes its own account. A number can only be live on one profile at a time. ## Cloud API only or coexistence Meta lets a business keep using the WhatsApp Business app on the same number while the API is connected. Meta calls this coexistence. It is convenient, but it switches features off. | Feature | Cloud API only | Coexistence | | --- | --- | --- | | Throughput | Meta's standard tiers | Fixed at 20 messages per second | | [Group chats](https://pozy.ai/docs/whatsapp/groups) | Yes | No | | [Calling](https://pozy.ai/docs/whatsapp/calling) | Yes | No | | Profile photo | Managed through the API | Locked, managed in the app | | Message history | Starts at connection | Up to 6 months synced from the app | | Sends from the phone | Not possible | Allowed, and reported on `message.sent` with `source: "whatsapp_business_app"` | Pass `onboarding=api` to connect Cloud API only. Leave it out, or pass `onboarding=business_app`, to offer coexistence. With coexistence the person connecting scans a QR code from the WhatsApp Business app during signup, so they need the phone with them. > **Warning:** > > Meta has no API to leave coexistence. The business disconnects it in the app (**Settings > Account > Business Platform > Disconnect**). Pozy then deactivates the account and sends `account.disconnected`. To get groups and calling afterwards, connect the number again with `onboarding=api`. ## Connect with Embedded Signup Embedded Signup is Meta's own signup window. You ask Pozy for a URL, send the person there, and they come back to your `redirect_url` with the new account id. ```bash cURL curl "https://api.pozy.ai/v1/connect/whatsapp?profileId=6710c4e2b9a1f03d8e5c7a21&redirect_url=https://app.example.com/whatsapp/done&onboarding=api" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/whatsapp?profileId=6710c4e2b9a1f03d8e5c7a21&redirect_url=https:%2F%2Fapp.example.com%2Fwhatsapp%2Fdone&onboarding=api", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); // Send the browser here console.log(data.authUrl); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/connect/whatsapp", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "profileId": "6710c4e2b9a1f03d8e5c7a21", "redirect_url": "https://app.example.com/whatsapp/done", "onboarding": "api", }, timeout=30, ) resp.raise_for_status() print(resp.json()["authUrl"]) ``` The person logs in to Meta, picks or creates a WABA and picks the number. They land back on your page with the result in the query string: ```text https://app.example.com/whatsapp/done?connected=whatsapp&profileId=6710c4e2b9a1f03d8e5c7a21&accountId=6712a9d4c0e3b58f1a7d2e64&username=%2B14155550142 ``` Store `accountId`. Every WhatsApp endpoint takes it. If signup fails, the redirect carries `error` and `platform=whatsapp` instead: | `error` | Meaning | | --- | --- | | `connection_cancelled` | The person closed Meta's window before finishing. | | `one_whatsapp_per_profile` | The profile rules allow no more WhatsApp numbers here. Use another profile. | | `whatsapp_number_pinned_to_profile` | A Pozy number that belongs to another profile. Connect it there or move it first. | | `whatsapp_number_already_connected` | The number is live on another profile or workspace. Disconnect it there first. | | `payment_required` | The workspace needs a payment method. | | `whatsapp_error` | Meta refused the signup. Meta's reason, when given, is in `error_message`. | ### Let Pozy host the signup page When one Facebook login manages several WhatsApp accounts (agency staff, a business with several brands), the redirect flow above can only learn that the person authorized, not which number they picked in Meta's window. Pozy then asks them to pick the number a second time. Add `signup=hosted` and `authUrl` points to a Pozy page instead of Meta's dialog. That page opens Meta's window itself, reads which number the person chose and connects exactly that one. You embed nothing and register no domain with Meta. | Parameter | Effect | Rules | | --- | --- | --- | | `brandName` | Page title reads "Connect your WhatsApp number to *brandName*" | 1 to 60 characters | | `primaryColor` | Colour of the main button and step accents | Hex `#RRGGBB` | | `language` | Language of the page and its checklist | `en` (default), `es` or `pt-BR` | These three only work with `signup=hosted`. Anywhere else they return `400`. The hosted page stays valid for 60 minutes; after that the person lands on your `redirect_url` with `error=session_expired` and you request a new URL. ### Pick the number in your own UI Pass `headless=true` if you want to show your own number picker. When the chosen WABA holds two or more numbers, the person lands on your `redirect_url` with `step=select_phone_number`, `profileId` and a short-lived `tempToken`. A WABA with one number connects straight away and skips this step. ```bash cURL # 1. List the numbers the person can pick from curl "https://api.pozy.ai/v1/connect/whatsapp/select-phone-number?profileId=6710c4e2b9a1f03d8e5c7a21&tempToken=$TEMP_TOKEN" \ -H "Authorization: Bearer $POZY_API_KEY" # 2. Connect the one they chose curl -X POST https://api.pozy.ai/v1/connect/whatsapp/select-phone-number \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6710c4e2b9a1f03d8e5c7a21", "phoneNumberId": "109876543210987", "wabaId": "204567890123456", "tempToken": "'"$TEMP_TOKEN"'" }' ``` ```ts Node const profileId = "6710c4e2b9a1f03d8e5c7a21"; const tempToken = new URL(callbackUrl).searchParams.get("tempToken")!; const listRes = await fetch(`https://api.pozy.ai/v1/connect/whatsapp/select-phone-number?${new URLSearchParams({ profileId, tempToken })}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const list = await listRes.json(); if (!listRes.ok) throw new Error(list.error); const chosen = list.phoneNumbers[0]; // show these in your own picker const res = await fetch("https://api.pozy.ai/v1/connect/whatsapp/select-phone-number", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId, phoneNumberId: chosen.id, wabaId: chosen.wabaId, tempToken }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.account.accountId); ``` ```python Python base = "https://api.pozy.ai/v1/connect/whatsapp/select-phone-number" headers = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} profile_id = "6710c4e2b9a1f03d8e5c7a21" numbers = requests.get( base, headers=headers, params={"profileId": profile_id, "tempToken": temp_token}, timeout=30, ).json()["phoneNumbers"] chosen = numbers[0] resp = requests.post( base, headers=headers, timeout=30, json={"profileId": profile_id, "phoneNumberId": chosen["id"], "wabaId": chosen["wabaId"], "tempToken": temp_token}, ) print(resp.json()["account"]["accountId"]) ``` Each listed number includes its `display_phone_number`, `verified_name`, `quality_rating`, `name_status` and `messaging_limit_tier`, which is enough to build a useful picker. ## Connect with a Meta token (no browser) If you already manage the business in Meta, connect from your server with a permanent System User token. This suits provisioning scripts and server-to-server setups. 1. In Meta Business Suite, create a System User and generate a token with `whatsapp_business_management` and `whatsapp_business_messaging`. Add `whatsapp_business_manage_events` if you want to send [Click-to-WhatsApp conversions](#measure-click-to-whatsapp-ads). 2. Copy the WABA id and the phone number id from WhatsApp Manager. 3. Call [`POST /v1/connect/whatsapp/credentials`](https://pozy.ai/docs/api/connectWhatsAppCredentials). Add `pin` if the number has two-step verification turned on. ```bash cURL curl -X POST https://api.pozy.ai/v1/connect/whatsapp/credentials \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6710c4e2b9a1f03d8e5c7a21", "accessToken": "EAAG...system-user-token", "wabaId": "204567890123456", "phoneNumberId": "109876543210987", "pin": "205718" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/connect/whatsapp/credentials", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6710c4e2b9a1f03d8e5c7a21", accessToken: process.env.META_SYSTEM_USER_TOKEN!, wabaId: "204567890123456", phoneNumberId: "109876543210987", pin: "205718", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.account.accountId, data.account.qualityRating); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/connect/whatsapp/credentials", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "6710c4e2b9a1f03d8e5c7a21", "accessToken": os.environ["META_SYSTEM_USER_TOKEN"], "wabaId": "204567890123456", "phoneNumberId": "109876543210987", "pin": "205718", }, timeout=30, ) resp.raise_for_status() print(resp.json()["account"]["accountId"]) ``` Pozy checks the token with Meta, creates the account, subscribes the WABA to message events and registers the number. If `phoneNumberId` is not in that WABA, the `400` lists the numbers that are. > **Existing webhooks on the WABA stop:** > > Connecting routes the WABA's events to Pozy. Any callback URL you had set on that WABA stops receiving events right away. Do not unsubscribe your Meta app from the WABA afterwards, or Pozy stops receiving messages too and you have to connect again. ## Set the business profile The business profile is what customers see when they open your chat. Update only the fields you send. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/business-profile \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "about": "Fresh bread, baked every morning", "description": "Neighbourhood bakery in Lisbon. Order by 6pm for next-day pickup.", "email": "hello@example.com", "websites": ["https://example.com"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/business-profile", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", about: "Fresh bread, baked every morning", description: "Neighbourhood bakery in Lisbon. Order by 6pm for next-day pickup.", email: "hello@example.com", websites: ["https://example.com"], }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/whatsapp/business-profile", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "about": "Fresh bread, baked every morning", "description": "Neighbourhood bakery in Lisbon. Order by 6pm for next-day pickup.", "email": "hello@example.com", "websites": ["https://example.com"], }, timeout=30, ).raise_for_status() ``` | Field | Limit | | --- | --- | | `about` | 139 characters | | `description` | 512 characters | | `websites` | 2 URLs | | `vertical` | One of Meta's business categories | The profile photo, display name and WhatsApp username have their own endpoints: - **Photo:** [`POST /v1/whatsapp/business-profile/photo`](https://pozy.ai/docs/api/uploadWhatsAppProfilePhoto). Locked on coexistence numbers. - **Display name:** request a change with [`POST /v1/whatsapp/business-profile/display-name`](https://pozy.ai/docs/api/updateWhatsAppDisplayName). Meta reviews every change, so the name starts as `PENDING_REVIEW`. The verdict arrives as `whatsapp.account.name_status_updated` (`APPROVED`, `DECLINED` or `PENDING_REVIEW`), and [`GET .../display-name`](https://pozy.ai/docs/api/getWhatsAppDisplayName) reads the current state. - **Username:** reserve a public username of up to 35 characters with [`POST /v1/whatsapp/business-profile/username`](https://pozy.ai/docs/api/setWhatsappBusinessUsername). [Suggestions](https://pozy.ai/docs/api/getWhatsappBusinessUsernameSuggestions) returns names that are free. ## Watch quality and limits Meta rates every number on how recipients react to its messages. A falling rating can lower the messaging limit or get templates paused. [`GET /v1/whatsapp/number-info`](https://pozy.ai/docs/api/getWhatsAppNumberInfo) reads the live state from Meta: | Field | Values | | --- | --- | | `phone.quality_rating` | `GREEN`, `YELLOW`, `RED` or `UNKNOWN` | | `phone.messaging_limit_tier` | `TIER_250` for a new number, then `TIER_2K`, `TIER_10K`, `TIER_100K`, `TIER_UNLIMITED` | | `phone.name_status` | Display name approval state | | `phone.status` | Meta's connection status, for example `CONNECTED` | | `waba.business_verification_status` | Whether Meta has verified the business behind the WABA | You do not have to poll. Pozy sends `whatsapp.account.quality_updated` when the rating or the tier changes, with both the old and the new value, and `whatsapp.account.status_updated` when Meta restricts or reinstates the WABA. A restriction lists what was blocked (for example `RESTRICTED_BIZ_INITIATED_MESSAGING`) and when it lifts. Business verification happens in Meta Business Manager, not through the API. Some features, such as conversion tracking for Click-to-WhatsApp ads, need a verified business. Read `waba.business_verification_status` before you turn them on. [Phone numbers for WhatsApp](https://pozy.ai/docs/whatsapp/phone-numbers#check-that-a-number-is-alive) explains how to tell a dead channel from a healthy one. ## Ice breakers and slash commands Give people something to tap when they open the chat. [`POST /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/setWhatsAppConversationalAutomation) sets: - up to 3 ice breaker `prompts`, 80 characters each - up to 30 slash `commands` (`command_name` up to 30 characters, `command_description` up to 256) - `enable_welcome_message`, which lets Meta greet first-time visitors A tapped prompt arrives as an ordinary `message.received` with the prompt text, so a keyword [workflow](https://pozy.ai/docs/automations/workflows) can answer it. ## Measure Click-to-WhatsApp ads When a conversation starts from a Click-to-WhatsApp ad, Meta attaches a click id to the first message. Pozy stores it on the conversation so you can report results back to Meta and see which ad paid off. 1. Create the dataset once per WABA with [`POST /v1/whatsapp/dataset`](https://pozy.ai/docs/api/createWhatsAppDataset). The call is safe to repeat. 2. When something happens in the chat, send it with [`POST /v1/whatsapp/conversions`](https://pozy.ai/docs/api/sendWhatsAppConversion): `eventName` (`LeadSubmitted`, `QualifiedLead`, `Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`, `OrderCreated`, `OrderCanceled` or `OrderReturned`), a stable `eventId` and the `conversationId`. Meta attributes events for 7 days after the click. Create the ads themselves from [Meta Ads](https://pozy.ai/docs/ads/meta-ads). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/connect/{platform}`](https://pozy.ai/docs/api/getConnectUrl) | Get OAuth connect URL | | [`GET /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/listWhatsAppPhoneNumbers) | List numbers for selection | | [`POST /v1/connect/whatsapp/select-phone-number`](https://pozy.ai/docs/api/completeWhatsAppPhoneSelection) | Complete number selection | | [`POST /v1/connect/whatsapp/credentials`](https://pozy.ai/docs/api/connectWhatsAppCredentials) | Connect WhatsApp via credentials | | [`POST /v1/connect/whatsapp/embedded-signup`](https://pozy.ai/docs/api/connectWhatsAppEmbeddedSignup) | Connect WhatsApp from Embedded Signup | | [`GET /v1/connect/whatsapp/sdk-config`](https://pozy.ai/docs/api/getWhatsAppSdkConfig) | Get Embedded Signup SDK config | | [`GET /v1/whatsapp/business-profile`](https://pozy.ai/docs/api/getWhatsAppBusinessProfile) | Get business profile | | [`POST /v1/whatsapp/business-profile`](https://pozy.ai/docs/api/updateWhatsAppBusinessProfile) | Update business profile | | [`POST /v1/whatsapp/business-profile/photo`](https://pozy.ai/docs/api/uploadWhatsAppProfilePhoto) | Upload profile picture | | [`GET /v1/whatsapp/business-profile/display-name`](https://pozy.ai/docs/api/getWhatsAppDisplayName) | Get display name status | | [`POST /v1/whatsapp/business-profile/display-name`](https://pozy.ai/docs/api/updateWhatsAppDisplayName) | Request display name change | | [`GET /v1/whatsapp/business-profile/username`](https://pozy.ai/docs/api/getWhatsappBusinessUsername) | Get business username | | [`POST /v1/whatsapp/business-profile/username`](https://pozy.ai/docs/api/setWhatsappBusinessUsername) | Set business username | | [`DELETE /v1/whatsapp/business-profile/username`](https://pozy.ai/docs/api/deleteWhatsappBusinessUsername) | Delete business username | | [`GET /v1/whatsapp/business-profile/username/suggestions`](https://pozy.ai/docs/api/getWhatsappBusinessUsernameSuggestions) | Get username suggestions | | [`GET /v1/whatsapp/number-info`](https://pozy.ai/docs/api/getWhatsAppNumberInfo) | Get number status | | [`GET /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/getWhatsAppConversationalAutomation) | Get ice breakers and commands | | [`POST /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/setWhatsAppConversationalAutomation) | Set ice breakers and commands | | [`DELETE /v1/whatsapp/conversational-automation`](https://pozy.ai/docs/api/deleteWhatsAppConversationalAutomation) | Clear ice breakers and commands | | [`GET /v1/whatsapp/block-users`](https://pozy.ai/docs/api/getWhatsAppBlockedUsers) | List blocked users | | [`GET /v1/whatsapp/block-users/status`](https://pozy.ai/docs/api/getWhatsAppBlockStatus) | Check if a user is blocked | | [`POST /v1/whatsapp/block-users`](https://pozy.ai/docs/api/blockWhatsAppUsers) | Block users | | [`DELETE /v1/whatsapp/block-users`](https://pozy.ai/docs/api/unblockWhatsAppUsers) | Unblock users | | [`GET /v1/whatsapp/account-events`](https://pozy.ai/docs/api/listWhatsAppAccountEvents) | List account notifications | | [`GET /v1/whatsapp/dataset`](https://pozy.ai/docs/api/getWhatsAppDataset) | Get CTWA conversions dataset | | [`POST /v1/whatsapp/dataset`](https://pozy.ai/docs/api/createWhatsAppDataset) | Provision CTWA dataset | | [`GET /v1/whatsapp/conversions`](https://pozy.ai/docs/api/listWhatsAppConversions) | List conversion events | | [`POST /v1/whatsapp/conversions`](https://pozy.ai/docs/api/sendWhatsAppConversion) | Send WhatsApp conversion event | ## Webhook events | Event | When it fires | | --- | --- | | [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`whatsapp.account.quality_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-quality-updated) | WhatsApp quality rating or messaging limit changed. | | [`whatsapp.account.status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-status-updated) | WhatsApp Business Account restricted or reinstated. | | [`whatsapp.account.alert_received`](https://pozy.ai/docs/webhooks/events#whatsapp-account-alert-received) | WhatsApp account alert received. | | [`whatsapp.contact.identity_changed`](https://pozy.ai/docs/webhooks/events#whatsapp-contact-identity-changed) | WhatsApp contact identity changed. | | [`whatsapp.account.name_status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-name-status-updated) | WhatsApp display-name review outcome. | | [`whatsapp.automatic_event`](https://pozy.ai/docs/webhooks/events#whatsapp-automatic-event) | WhatsApp automatic event detected. | --- # Message templates Create WhatsApp templates, follow Meta's review, fill variables, media headers and buttons, and send a template to start a conversation. A template is a pre-approved message. It is the only thing you can send to someone who has not messaged you in the last 24 hours, so every outbound campaign, reminder and one-time code on WhatsApp starts with one. You create the template, Meta reviews it, and once it is `APPROVED` you can send it as often as you like with different values filled in. ## Create a template Send [`POST /v1/whatsapp/templates`](https://pozy.ai/docs/api/createWhatsAppTemplate) with the account, a `name`, a `category`, a `language` and the `components` that make up the message. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/templates \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "name": "pickup_ready", "category": "UTILITY", "language": "en", "components": [ { "type": "body", "text": "Hi {{1}}, your order {{2}} is ready for pickup until {{3}}.", "example": { "body_text": [["Marta", "#4821", "6pm"]] } }, { "type": "footer", "text": "Reply STOP to opt out" } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/templates", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", name: "pickup_ready", category: "UTILITY", language: "en", components: [ { type: "body", text: "Hi {{1}}, your order {{2}} is ready for pickup until {{3}}.", example: { body_text: [["Marta", "#4821", "6pm"]] }, }, { type: "footer", text: "Reply STOP to opt out" }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.template.id, data.template.status); // "PENDING" ``` ```python Python import os import requests resp = requests.post( "https://api.pozy.ai/v1/whatsapp/templates", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "name": "pickup_ready", "category": "UTILITY", "language": "en", "components": [ { "type": "body", "text": "Hi {{1}}, your order {{2}} is ready for pickup until {{3}}.", "example": {"body_text": [["Marta", "#4821", "6pm"]]}, }, {"type": "footer", "text": "Reply STOP to opt out"}, ], }, timeout=30, ) resp.raise_for_status() print(resp.json()["template"]) ``` ```json { "success": true, "template": { "id": "781203456789012", "name": "pickup_ready", "status": "PENDING", "category": "UTILITY", "language": "en" } } ``` Rules for the request: - `name` uses lowercase letters, digits and underscores and starts with a letter. - Meta stores one template per name and language. The same `name` in three languages is three templates, each with its own `id`. - Every variable needs an `example` value. Reviewers reject templates they cannot read with real values. ## Pick the category The category decides what Meta charges per delivery and how long Meta keeps trying to deliver. Pick the one that matches the content honestly; Meta reclassifies templates that are filed under the wrong one. | `category` | Use it for | Delivery window you can set | Meta's default | | --- | --- | --- | --- | | `UTILITY` | Order updates, receipts, reminders, account notices | 30 seconds to 12 hours | 30 days | | `MARKETING` | Promotions, offers, newsletters, re-engagement | 12 hours to 30 days | 30 days | | `AUTHENTICATION` | One-time passcodes | 30 seconds to 15 minutes | 10 minutes | Set the window with `message_send_ttl_seconds`. A message not delivered inside it is dropped rather than delivered hours afterwards. That is right for a passcode and wrong for a promotion. On create you can pass `-1` to keep the 30-day default for `UTILITY` and `AUTHENTICATION`. When Meta changes a category it sends `whatsapp.template.category_updated` twice: once with `changeType: "scheduled"` as a 24-hour notice, and again with `changeType: "applied"` when it takes effect. Meta clears any custom delivery window at that point, so read it back afterwards. ## Build the components A template is a list of components. Only `body` is required. | Component | What it holds | Notes | | --- | --- | --- | | `header` | `format` is `text`, `image`, `video`, `gif`, `document` or `location` | A text header can carry one variable. A media header needs an `example.header_handle` for review and real media at send time. | | `body` | The main text with variables | `add_security_recommendation` adds Meta's standard warning on authentication templates. | | `footer` | Short plain text | `code_expiration_minutes` on authentication templates. | | `buttons` | Quick replies, links, phone, code and flow buttons | Types: `quick_reply`, `url`, `phone_number`, `otp`, `copy_code`, `flow`, `mpm`, `catalog`. | | `carousel` | Up to 10 cards, each with its own header, body and buttons | Every card must use the same structure. | | `limited_time_offer` | Offer text (16 characters) and an optional expiry | Marketing templates only. | ### Variables Variables are positional by default: `{{1}}`, `{{2}}` and so on. To use names, set `parameter_format: "NAMED"` and write `{{customer_name}}` in the text, with the examples in `body_text_named_params`. [Broadcasts](https://pozy.ai/docs/whatsapp/broadcasts#fill-variables-per-recipient) only fill positional variables. Use positional ones if you plan to broadcast the template. ### Buttons ```json { "type": "buttons", "buttons": [ { "type": "quick_reply", "text": "Change pickup time" }, { "type": "url", "text": "View order", "url": "https://example.com/orders/{{1}}", "example": ["4821"] }, { "type": "phone_number", "text": "Call the shop", "phone_number": "+351210000123" } ] } ``` A `url` button can end in one variable, filled at send time. A `flow` button opens a [WhatsApp Flow](https://pozy.ai/docs/whatsapp/flows) by `flow_id` or `flow_name`. ## Wait for approval Review can take up to 24 hours. Subscribe to `whatsapp.template.status_updated` instead of polling. It fires whenever Meta finishes a review or re-review and carries the new `status` and Meta's `reason` (`"NONE"` on approval). | `status` | Meaning | Can you send it? | | --- | --- | --- | | `PENDING` | In review | No | | `APPROVED` | Live | Yes | | `REJECTED` | Declined. Fix the content and create it again, or appeal in WhatsApp Manager. | No | | `IN_APPEAL` | An appeal is being reviewed | No | | `PAUSED` | Meta paused it after poor recipient feedback | No | | `DISABLED` | Switched off by Meta | No | | `PENDING_DELETION` | Delete requested; removed after 24 hours | No | To check by hand, list templates with [`GET /v1/whatsapp/templates`](https://pozy.ai/docs/api/getWhatsAppTemplates) and filter by `status`, `name` or `language`. The list is read live from Meta. > **Skip the review with Meta's library:** > > Meta keeps a library of pre-approved utility templates. Look one up with [`GET /v1/whatsapp/template-library`](https://pozy.ai/docs/api/getWhatsAppLibraryTemplate), then create it with `library_template_name` instead of `components`. It comes back `APPROVED` immediately. If the library template has URL or phone buttons, send one `library_template_button_inputs` entry per button, in order, or Meta rejects it. ## Send a template To open a conversation, call [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation). The recipient goes in `participantId` as digits with the country code and no `+`. `templateParams` is one flat list in the order the variables appear: header variable first, then body variables. ```bash cURL curl -X POST https://api.pozy.ai/v1/inbox/conversations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "participantId": "351912345678", "templateName": "pickup_ready", "templateLanguage": "en", "templateParams": ["Marta", "#4821", "6pm"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/conversations", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", participantId: "351912345678", templateName: "pickup_ready", templateLanguage: "en", templateParams: ["Marta", "#4821", "6pm"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.data.conversationId, data.data.messageId); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/inbox/conversations", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "participantId": "351912345678", "templateName": "pickup_ready", "templateLanguage": "en", "templateParams": ["Marta", "#4821", "6pm"], }, timeout=30, ) print(resp.json()["data"]) ``` Other parts of the template are filled with their own fields on the same call: | Field | Fills | | --- | --- | | `headerMedia` | The image, video or document of a media header (`type` plus a public `link` or an uploaded media `id`) | | `headerLocation` | A location header (`latitude`, `longitude`, optional `name` and `address`) | | `templateButtonParams` | Button variables by `index`, with `subType` `url`, `copy_code` or `flow` | | `templateCards` | Per-card values for a carousel (`cardIndex`, `params`, `headerMedia`, `buttons`) | `templateLanguage` must match exactly. `en` and `en_US` are two different templates. Inside an existing conversation, send a template with [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage). To send the same template to many people with per-person values, use a [broadcast](https://pozy.ai/docs/whatsapp/broadcasts). ## Edit or delete a template Update by name with [`PATCH /v1/whatsapp/templates/{templateName}`](https://pozy.ai/docs/api/updateWhatsAppTemplate) or by Meta id with [`PATCH /v1/whatsapp/templates/id/{templateId}`](https://pozy.ai/docs/api/updateWhatsAppTemplateById). Put `language` in the body to say which variant you mean. A name that exists in several languages without `language` returns `409` with code `ambiguous_template`. - Changing `components` sends the template back to review and returns `status: "PENDING"`. - Changing only `message_send_ttl_seconds` keeps an approved template approved. `-1` is not allowed here. - Meta allows one edit per 24 hours and 10 per 30 days, and only from `APPROVED`, `REJECTED` or `PAUSED`. Deleting moves a template to `PENDING_DELETION` for 24 hours before Meta removes it. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` on create | Name format, missing example or a category outside the three values | Check the rules above and resend | | `400` with `param: message_send_ttl_seconds` | Window outside the range for the category | Use a value from the category table | | Meta code `132001`, template not found | Name or language code does not match | Use the exact name and language the template was created with | | Meta code `132000`, parameter count | Fewer or more `templateParams` than variables | Send one value per variable, in order | | `REJECTED` status | Review failed; not an API error | Read `reason` on `whatsapp.template.status_updated`, fix the text, create it again | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/templates`](https://pozy.ai/docs/api/getWhatsAppTemplates) | List templates | | [`POST /v1/whatsapp/templates`](https://pozy.ai/docs/api/createWhatsAppTemplate) | Create template | | [`GET /v1/whatsapp/templates/{templateName}`](https://pozy.ai/docs/api/getWhatsAppTemplate) | Get template | | [`PATCH /v1/whatsapp/templates/{templateName}`](https://pozy.ai/docs/api/updateWhatsAppTemplate) | Update template | | [`DELETE /v1/whatsapp/templates/{templateName}`](https://pozy.ai/docs/api/deleteWhatsAppTemplate) | Delete template | | [`GET /v1/whatsapp/templates/id/{templateId}`](https://pozy.ai/docs/api/getWhatsAppTemplateById) | Get template by id | | [`PATCH /v1/whatsapp/templates/id/{templateId}`](https://pozy.ai/docs/api/updateWhatsAppTemplateById) | Update template by id | | [`DELETE /v1/whatsapp/templates/id/{templateId}`](https://pozy.ai/docs/api/deleteWhatsAppTemplateById) | Delete template by id | | [`GET /v1/whatsapp/template-library`](https://pozy.ai/docs/api/getWhatsAppLibraryTemplate) | Look up a library template | | [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | ## Webhook events | Event | When it fires | | --- | --- | | [`whatsapp.template.status_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-template-status-updated) | WhatsApp template status updated. | | [`whatsapp.template.category_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-template-category-updated) | WhatsApp template category updated. | --- # WhatsApp Flows Build native forms, surveys and booking screens inside WhatsApp, publish them, send them and read what customers submit. A WhatsApp Flow is a set of screens that opens inside the chat: a lead form, a booking picker, a short survey. The customer fills it in without leaving WhatsApp and you get structured data back instead of free text. You define the screens in Meta's Flow JSON, publish the flow, then send it with a button. ## How a flow moves through its life | Status | What you can do | | --- | --- | | `DRAFT` | Upload and replace the JSON, rename it, change categories, send it to yourself for testing | | `PUBLISHED` | Send it to anyone. The flow and its JSON can no longer change. | | `DEPRECATED` | Nothing. It can no longer be sent. | | `BLOCKED` | Meta blocked it for a policy issue | | `THROTTLED` | Meta is rate-limiting it for now | Publishing cannot be undone. To change a published flow you create a new version, which starts as a fresh draft (see [Version a flow](#version-a-flow)). ## Build and send a flow ### Create the flow Call [`POST /v1/whatsapp/flows`](https://pozy.ai/docs/api/createWhatsAppFlow) with a `name` (up to 128 characters) and one or more `categories`: `SIGN_UP`, `SIGN_IN`, `APPOINTMENT_BOOKING`, `LEAD_GENERATION`, `CONTACT_US`, `CUSTOMER_SUPPORT`, `SURVEY` or `OTHER`. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/flows \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "name": "catering_quote", "categories": ["LEAD_GENERATION"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/flows", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", name: "catering_quote", categories: ["LEAD_GENERATION"], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const flowId = data.flow.id; // status "DRAFT", version 1 ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/whatsapp/flows", headers=H, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "name": "catering_quote", "categories": ["LEAD_GENERATION"], }, timeout=30, ) flow_id = resp.json()["flow"]["id"] ``` ### Upload the screens Send the Flow JSON to [`PUT /v1/whatsapp/flows/{flowId}/json`](https://pozy.ai/docs/api/uploadWhatsAppFlowJson). This one-screen form asks for a name, an email and a guest count, then completes with those values. ```bash cURL curl -X PUT "https://api.pozy.ai/v1/whatsapp/flows/$FLOW_ID/json" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "flow_json": { "version": "6.0", "screens": [{ "id": "QUOTE", "title": "Catering quote", "terminal": true, "success": true, "layout": { "type": "SingleColumnLayout", "children": [ { "type": "TextInput", "name": "name", "label": "Your name", "required": true, "input-type": "text" }, { "type": "TextInput", "name": "email", "label": "Email", "required": true, "input-type": "email" }, { "type": "TextInput", "name": "guests", "label": "Number of guests", "required": true, "input-type": "number" }, { "type": "Footer", "label": "Send request", "on-click-action": { "name": "complete", "payload": { "name": "${form.name}", "email": "${form.email}", "guests": "${form.guests}" } } } ] } }] } }' ``` Meta validates the JSON on upload. A `200` does not mean it passed: check `validation_errors`, which names the error with its line and column. A flow with validation errors cannot be published. To look at the flow before you publish, [`GET /v1/whatsapp/flows/{flowId}/preview`](https://pozy.ai/docs/api/getWhatsAppFlowPreview) returns a public `preview_url` that works without a login and can sit in an iframe. It stays the same for about 30 days. Pass `invalidate=true` to issue a new one and kill the old link. ### Publish Call [`POST /v1/whatsapp/flows/{flowId}/publish`](https://pozy.ai/docs/api/publishWhatsAppFlow) with the `accountId`. From here the flow is read-only. ### Send it Call [`POST /v1/whatsapp/flows/send`](https://pozy.ai/docs/api/sendWhatsAppFlowMessage). The customer receives a message with a button labelled `flow_cta` (up to 20 characters) that opens the flow. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/flows/send \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "to": "+351912345678", "flow_id": "'"$FLOW_ID"'", "flow_cta": "Get a quote", "flow_action": "navigate", "flow_action_payload": { "screen": "QUOTE" }, "body": "Tell us about your event and we will reply with a quote today." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/flows/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", to: "+351912345678", flow_id: flowId, flow_cta: "Get a quote", flow_action: "navigate", flow_action_payload: { screen: "QUOTE" }, body: "Tell us about your event and we will reply with a quote today.", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/whatsapp/flows/send", headers=H, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "to": "+351912345678", "flow_id": flow_id, "flow_cta": "Get a quote", "flow_action": "navigate", "flow_action_payload": {"screen": "QUOTE"}, "body": "Tell us about your event and we will reply with a quote today.", }, timeout=30, ).raise_for_status() ``` Add `draft: true` to send an unpublished flow while you test. A flow message is a normal message, so the 24-hour window applies: to reach someone who has not written to you recently, send a template with a `flow` button instead (see [Templates](https://pozy.ai/docs/whatsapp/templates#buttons)). ## Read the answers Each completed flow arrives as a `message.received` event with the submitted values. If you would rather not dig through the inbox, list the submissions for one flow with [`GET /v1/whatsapp/flow-responses`](https://pozy.ai/docs/api/listWhatsAppFlowResponses): ```bash cURL curl "https://api.pozy.ai/v1/whatsapp/flow-responses?accountId=6712a9d4c0e3b58f1a7d2e64&flowId=$FLOW_ID&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "responses": [ { "id": "wamid.HBgMMzUxOTEyMzQ1Njc4FQIAEhgUM0E1", "receivedAt": "2026-10-28T14:02:11Z", "from": "351912345678", "senderName": "Marta Silva", "conversationId": "6714c1f6e2a5d70b3c9f4a86", "flowToken": "781203456789099:6b0f3c1e-8a52-4d7e-9c1b-2f4e6a8d0b13", "data": { "name": "Marta Silva", "email": "marta@example.com", "guests": "40" } } ] } ``` Responses come back newest first, up to 200 per call. Pozy can tie a response to its flow because, when you leave `flow_token` out on send, it generates one in the form `:`. If you pass your own `flow_token`, the response still reaches your webhook but is not grouped under the flow here. ## Two ways to run the screens `flow_action` on the send decides where the screens come from. | `flow_action` | How it works | What you host | | --- | --- | --- | | `navigate` (default) | WhatsApp renders the screens from the published JSON | Nothing | | `data_exchange` | WhatsApp posts each screen's data to your endpoint and shows the screen your endpoint returns, so screens can depend on live data such as available time slots | An HTTPS endpoint and an RSA key pair | For `data_exchange`: 1. Set `endpointUri` (HTTPS) when you create the flow, or with [`PATCH /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/updateWhatsAppFlow) while it is a draft. 2. Declare `data_api_version` `"3.0"` in the Flow JSON. 3. Register the public half of your RSA key with [`POST /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/setWhatsAppFlowsEncryptionKey) (`businessPublicKey`, PEM). Keep the private half on your endpoint. One key is active per number; uploading another replaces it. Check the key with [`GET /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/getWhatsAppFlowsEncryptionKey) and look at `registered`. Publishing a `data_exchange` flow with no key fails with Meta error `139002`. A key whose private half your endpoint does not hold passes publish and then fails when a customer opens the flow, so test with `draft: true` first. ## Version a flow Because a published flow cannot change, you iterate by cloning. Pass `cloneFlowId` to [`POST /v1/whatsapp/flows`](https://pozy.ai/docs/api/createWhatsAppFlow): - With `asVersion: true`, the copy is the next version of the same flow (same `lineageId`, `version` goes up by one). - Without it, the copy is a separate flow starting at version 1. [`GET /v1/whatsapp/flows/{flowId}/versions`](https://pozy.ai/docs/api/listWhatsAppFlowVersions) lists every version newest first, with its live status. `missing: true` marks a version Meta no longer has. When the new version is live, retire the old one with [`POST /v1/whatsapp/flows/{flowId}/deprecate`](https://pozy.ai/docs/api/deprecateWhatsAppFlow). ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `200` with entries in `validation_errors` | Meta rejected part of the Flow JSON | Fix the line and column named and upload again | | `400` on upload | Body is not valid JSON, or the flow is no longer a draft | Validate the JSON; clone a new version to edit a published flow | | `400` on publish | The flow still has validation errors | Upload a clean JSON first | | Meta error `139002` on publish | `data_exchange` flow with no encryption key registered | Register the key, then publish | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/flows`](https://pozy.ai/docs/api/listWhatsAppFlows) | List flows | | [`POST /v1/whatsapp/flows`](https://pozy.ai/docs/api/createWhatsAppFlow) | Create flow | | [`GET /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/getWhatsAppFlow) | Get flow | | [`PATCH /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/updateWhatsAppFlow) | Update flow | | [`DELETE /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/deleteWhatsAppFlow) | Delete flow | | [`GET /v1/whatsapp/flows/{flowId}/json`](https://pozy.ai/docs/api/getWhatsAppFlowJson) | Get flow JSON asset | | [`PUT /v1/whatsapp/flows/{flowId}/json`](https://pozy.ai/docs/api/uploadWhatsAppFlowJson) | Upload flow JSON | | [`GET /v1/whatsapp/flows/{flowId}/preview`](https://pozy.ai/docs/api/getWhatsAppFlowPreview) | Get flow preview URL | | [`GET /v1/whatsapp/flows/{flowId}/versions`](https://pozy.ai/docs/api/listWhatsAppFlowVersions) | List flow versions | | [`POST /v1/whatsapp/flows/{flowId}/publish`](https://pozy.ai/docs/api/publishWhatsAppFlow) | Publish flow | | [`POST /v1/whatsapp/flows/{flowId}/deprecate`](https://pozy.ai/docs/api/deprecateWhatsAppFlow) | Deprecate flow | | [`GET /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/getWhatsAppFlowsEncryptionKey) | Get Flows encryption key status | | [`POST /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/setWhatsAppFlowsEncryptionKey) | Register a Flows encryption key | | [`POST /v1/whatsapp/flows/send`](https://pozy.ai/docs/api/sendWhatsAppFlowMessage) | Send flow message | | [`GET /v1/whatsapp/flow-responses`](https://pozy.ai/docs/api/listWhatsAppFlowResponses) | List flow responses | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | --- # Group chats Create WhatsApp groups owned by your business number, manage participants, invite links and join requests, and message the group from the inbox. Your business number can own real WhatsApp groups: a support room with a client, a cohort for a course, a coordination group for a delivery team. Pozy creates the group, manages who is in it, and routes every group message through the same inbox endpoints you use for one-to-one chats. Groups are not contact lists. To send the same message to many people individually, use a [broadcast](https://pozy.ai/docs/whatsapp/broadcasts). ## Requirements | Requirement | Why | | --- | --- | | A Cloud API-only number | Meta turns the Groups API off for numbers in coexistence with the WhatsApp Business app. Connect with `onboarding=api` (see [Connect WhatsApp](https://pozy.ai/docs/whatsapp#cloud-api-only-or-coexistence)). | | Official Business Account status on the number | Meta only grants group creation to numbers it has marked as an official business account. The `is_official_business_account` field of [number info](https://pozy.ai/docs/api/getWhatsAppNumberInfo) tells you. | Meta's limits on groups: | Limit | Value | | --- | --- | | Participants per group | 8 in total | | Groups per number | 10,000 | | `subject` | 128 characters | | `description` | 2,048 characters | ## Create a group Call [`POST /v1/whatsapp/wa-groups`](https://pozy.ai/docs/api/createWhatsAppGroupChat) with a `subject` and, optionally, a `description` and a `joinApprovalMode`: - `approval_required`: people who open the invite link wait until you approve them. - `auto_approve`: anyone with the link joins straight away. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/wa-groups \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "subject": "Acme x Northwind launch", "description": "Launch week coordination. Questions go here.", "joinApprovalMode": "approval_required" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/wa-groups", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", subject: "Acme x Northwind launch", description: "Launch week coordination. Questions go here.", joinApprovalMode: "approval_required", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.group.groupId, data.group.inviteLink); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/whatsapp/wa-groups", headers=H, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "subject": "Acme x Northwind launch", "description": "Launch week coordination. Questions go here.", "joinApprovalMode": "approval_required", }, timeout=30, ) group = resp.json()["group"] print(group["groupId"], group["inviteLink"]) ``` The response returns `201` with the `groupId` and an `inviteLink` (`https://chat.whatsapp.com/...`). List the number's active groups with [`GET /v1/whatsapp/wa-groups`](https://pozy.ai/docs/api/listWhatsAppGroupChats), which pages with `after`. ## Bring people in WhatsApp treats groups as invite-only, so the invite link is the normal way in. You can also add numbers directly. | Task | Call | | --- | --- | | Add people | [`POST /v1/whatsapp/wa-groups/{groupId}/participants`](https://pozy.ai/docs/api/addWhatsAppGroupParticipants) with `phoneNumbers` in E.164 (`?accountId=` in the query) | | Remove people | [`DELETE .../participants`](https://pozy.ai/docs/api/removeWhatsAppGroupParticipants) with the same body | | Get a fresh invite link | [`POST .../invite-link`](https://pozy.ai/docs/api/createWhatsAppGroupInviteLink). The previous link stops working. | | See who is waiting | [`GET .../join-requests`](https://pozy.ai/docs/api/listWhatsAppGroupJoinRequests). Each entry has the phone and a Unix timestamp. | | Let them in | [`POST .../join-requests`](https://pozy.ai/docs/api/approveWhatsAppGroupJoinRequests) with `phoneNumbers` | | Turn them away | [`DELETE .../join-requests`](https://pozy.ai/docs/api/rejectWhatsAppGroupJoinRequests) with `phoneNumbers` | ```bash cURL curl -X POST "https://api.pozy.ai/v1/whatsapp/wa-groups/120363401234567890/participants?accountId=6712a9d4c0e3b58f1a7d2e64" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumbers": ["+351912345678", "+447700900123"] }' ``` > **The 8-person cap is per group, not per request:** > > Splitting a long list across several calls does not get a group past 8 participants. A `400` on add usually means the group is full. Change the subject, description or join mode later with [`POST /v1/whatsapp/wa-groups/{groupId}`](https://pozy.ai/docs/api/updateWhatsAppGroupChat). [`GET`](https://pozy.ai/docs/api/getWhatsAppGroupChat) on the same path reads the group, and [`DELETE`](https://pozy.ai/docs/api/deleteWhatsAppGroupChat) removes it. ## Talk in the group A group shows up in the inbox as a conversation once the first message arrives in it. There is no call to open a group conversation from your side, so ask a participant to say hello first (or share the invite link with a prompt to introduce themselves). When that message arrives, `message.received` carries the `conversationId`. From then on, send to the group with [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage), exactly as you would in a one-to-one chat: ```bash cURL curl -X POST "https://api.pozy.ai/v1/inbox/conversations/6714c1f6e2a5d70b3c9f4a86/messages" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "message": "Welcome everyone. The launch checklist is pinned in the description." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/conversations/6714c1f6e2a5d70b3c9f4a86/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", message: "Welcome everyone. The launch checklist is pinned in the description.", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.post( "https://api.pozy.ai/v1/inbox/conversations/6714c1f6e2a5d70b3c9f4a86/messages", headers=H, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "message": "Welcome everyone. The launch checklist is pinned in the description.", }, timeout=30, ).raise_for_status() ``` Every participant's messages land in that conversation, so inbox webhooks and [workflows](https://pozy.ai/docs/automations/workflows) see group messages like any other. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` with `type: platform_error` on any group call | The number is in coexistence, so Meta refuses group calls. Meta's own code is in `platformError`. | Reconnect the number with `onboarding=api` | | `400` when adding participants | The group already has 8 people | Remove someone, or use a broadcast to reach more people | | No conversation in the inbox | Nobody has written in the group yet | Wait for the first participant message | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/wa-groups`](https://pozy.ai/docs/api/listWhatsAppGroupChats) | List active groups | | [`POST /v1/whatsapp/wa-groups`](https://pozy.ai/docs/api/createWhatsAppGroupChat) | Create group | | [`GET /v1/whatsapp/wa-groups/{groupId}`](https://pozy.ai/docs/api/getWhatsAppGroupChat) | Get group info | | [`POST /v1/whatsapp/wa-groups/{groupId}`](https://pozy.ai/docs/api/updateWhatsAppGroupChat) | Update group settings | | [`DELETE /v1/whatsapp/wa-groups/{groupId}`](https://pozy.ai/docs/api/deleteWhatsAppGroupChat) | Delete group | | [`POST /v1/whatsapp/wa-groups/{groupId}/participants`](https://pozy.ai/docs/api/addWhatsAppGroupParticipants) | Add participants | | [`DELETE /v1/whatsapp/wa-groups/{groupId}/participants`](https://pozy.ai/docs/api/removeWhatsAppGroupParticipants) | Remove participants | | [`POST /v1/whatsapp/wa-groups/{groupId}/invite-link`](https://pozy.ai/docs/api/createWhatsAppGroupInviteLink) | Create invite link | | [`GET /v1/whatsapp/wa-groups/{groupId}/join-requests`](https://pozy.ai/docs/api/listWhatsAppGroupJoinRequests) | List join requests | | [`POST /v1/whatsapp/wa-groups/{groupId}/join-requests`](https://pozy.ai/docs/api/approveWhatsAppGroupJoinRequests) | Approve join requests | | [`DELETE /v1/whatsapp/wa-groups/{groupId}/join-requests`](https://pozy.ai/docs/api/rejectWhatsAppGroupJoinRequests) | Reject join requests | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | --- # WhatsApp calling Turn on WhatsApp voice calls for a number, route them to a phone, SIP endpoint or AI agent, ask customers for permission and place outbound calls. With calling turned on, customers can tap the call button in your WhatsApp chat and reach you, and you can call customers who have said yes. Pozy bridges every call to a destination you choose: a phone line, a SIP endpoint such as an AI voice agent, or a WebSocket media server you run. ## What you need | Requirement | Detail | | --- | --- | | A Cloud API-only number | Calling is off for numbers in coexistence with the WhatsApp Business app | | Usage-based billing on the workspace | Calls meter per minute ([Pricing](https://pozy.ai/docs/whatsapp/pricing)) | | A messaging limit of about 2,000 daily recipients or more | Meta's threshold for calling. A new number starts at `TIER_250`; send normal traffic until Meta raises it. | | A country where Meta allows business-initiated calls | Meta blocks outbound calls from numbers registered in the US, Canada, Egypt, Vietnam and Nigeria. Inbound calls to those numbers still work. | If a requirement is missing, enabling calling returns `422`. ## Where a call can go `forwardTo` takes one of three kinds of address: | Prefix | Example | Use it for | | --- | --- | --- | | `tel:` | `tel:+351210000123` | A desk phone or a mobile | | `sip:` | `sip:agent@voice.example.com` | A PBX, a contact centre or an AI voice agent with a SIP address | | `wss:` | `wss://media.example.com/calls` | Your own media server. Closing the socket ends the call. | Calls to a `sip:` or `wss:` destination have no phone-network leg, so there is no carrier charge for that leg. ## Turn calling on ### Read the current config [`GET /v1/whatsapp/calling?accountId=...`](https://pozy.ai/docs/api/getWhatsAppCallingConfig) returns `phoneNumberDocId`, the number id every other calling call takes, plus the current destination, recording flag and `callDeepLink`. ### Enable it with a destination Call [`POST /v1/phone-numbers/{id}/whatsapp/calling`](https://pozy.ai/docs/api/enableWhatsAppCalling) with the account and `forwardTo`. ```bash cURL curl -X POST "https://api.pozy.ai/v1/phone-numbers/6715d2a7f3b6e81c4d0a5b97/whatsapp/calling" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "forwardTo": "sip:frontdesk@voice.example.com", "recordingEnabled": false, "maxCallDurationSeconds": 1800 }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/6715d2a7f3b6e81c4d0a5b97/whatsapp/calling", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", forwardTo: "sip:frontdesk@voice.example.com", recordingEnabled: false, maxCallDurationSeconds: 1800, }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} requests.post( "https://api.pozy.ai/v1/phone-numbers/6715d2a7f3b6e81c4d0a5b97/whatsapp/calling", headers=H, json={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "forwardTo": "sip:frontdesk@voice.example.com", "recordingEnabled": False, "maxCallDurationSeconds": 1800, }, timeout=30, ).raise_for_status() ``` ### Share the call link Once calling is on, `callDeepLink` in the config is a public `https://wa.me/call/...` link. Put it on your website, in emails or behind a QR code and it starts a WhatsApp call to the number. Options on enable and on [`PATCH`](https://pozy.ai/docs/api/updateWhatsAppCalling): | Field | Default | Effect | | --- | --- | --- | | `recordingEnabled` | `false` | Records calls. A consent prompt plays first, and a per-minute recording charge applies. | | `maxCallDurationSeconds` | none | Hangs up after this many seconds (up to 14,400) so a destination that never hangs up cannot run up minutes | | `forwardCallerId` | `business` | `caller` shows the customer's number to a `sip:` destination instead of yours | | `sipAuthUsername`, `sipAuthPassword` | none | Credentials if your SIP destination requires them | | `callIconCountries` | none | Two-letter country codes where WhatsApp shows the call button in the chat | [`DELETE`](https://pozy.ai/docs/api/disableWhatsAppCalling) turns calling off but keeps the destination and credentials, so turning it back on needs no setup. ## Call a customer WhatsApp requires the customer's permission before a business calls them. ### Check permission [`GET /v1/whatsapp/call-permissions?accountId=...&to=...`](https://pozy.ai/docs/api/getWhatsAppCallPermissions) returns the permission `status` (`permanent`, `temporary` with an `expiration_time`, or `no_permission`) and which actions are allowed right now. ```json { "permission": { "status": "no_permission" }, "actions": [ { "action_name": "send_call_permission_request", "can_perform_action": true }, { "action_name": "start_call", "can_perform_action": false } ] } ``` ### Ask for permission if needed Send the request with [`POST /v1/whatsapp/calls`](https://pozy.ai/docs/api/initiateWhatsAppCall) and `action: "send_call_permission_request"`, optionally with your own `bodyText` (up to 1,024 characters). The customer taps Allow in the chat. Their answer arrives as `call.permission_request`. Meta allows one request per customer per 24 hours and two per 7 days, and only while a 24-hour service window is open. ### Place the call When `start_call` is allowed, call [`POST /v1/whatsapp/calls`](https://pozy.ai/docs/api/initiateWhatsAppCall) with `to`. The call bridges to the number's `forwardTo`, or to a `forwardTo` you pass on this call. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/calls \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: callback-order-4821" \ -d '{ "accountId": "6712a9d4c0e3b58f1a7d2e64", "to": "+351912345678" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "callback-order-4821", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6712a9d4c0e3b58f1a7d2e64", to: "+351912345678" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.callId, data.status); // "dialing" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/whatsapp/calls", headers={**H, "Idempotency-Key": "callback-order-4821"}, json={"accountId": "6712a9d4c0e3b58f1a7d2e64", "to": "+351912345678"}, timeout=30, ) print(resp.json()["callId"]) ``` Always send an `Idempotency-Key`. A retry after a timeout then returns the first response instead of dialling, and billing, a second call. To hang up, ringing or answered, inbound or outbound, call [`POST /v1/voice/calls/{id}/end`](https://pozy.ai/docs/api/endVoiceCall) with the `callId` (for inbound calls, the call id on `call.received`). ## Estimate and review costs Before dialling, [`GET /v1/whatsapp/calls/estimate`](https://pozy.ai/docs/api/getWhatsAppCallEstimate) with `to` and `minutes` returns the per-minute price and a breakdown: Meta's fee, the connection charge and recording if you ask for it. [`GET /v1/whatsapp/calls`](https://pozy.ai/docs/api/listWhatsAppCalls) lists past calls, filtered by `status`, `direction`, `since` and `until`, up to 200 per page. Pass `nextCursor` back as `before` for the next page. Each call has its duration, `endReason` (`hangup`, `no_answer`, `rejected`, `error`), a `recordingUrl` when recording was on, and `billing`: | Field | Meaning | | --- | --- | | `billing.billableCostUSD` | What Pozy bills for the call | | `billing.recordingCostUSD` | The recording part of that amount | | `billing.metaCostUSD` | Meta's own per-minute fee, billed by Meta to your WABA. Shown for reference. Always `0` on inbound calls. | | `billing.totalCostUSD` | Both together | Fetch a recording file with [`GET /v1/whatsapp/calls/{id}/recording`](https://pozy.ai/docs/api/getWhatsAppCallRecording). ## Caller ID on numbers you brought yourself When a call is forwarded to a `tel:` destination, the phone that rings shows a caller ID. A number bought through Pozy shows itself straight away. A number you brought from your own carrier cannot until it is verified, because carriers block unverified caller IDs; until then the forwarded call shows a Pozy number and the config reads `callerIdMode: "platform"`. To verify it: 1. Call [`POST /v1/phone-numbers/{id}/whatsapp/caller-id-verification`](https://pozy.ai/docs/api/startWhatsAppCallerIdVerification). The number receives a one-time code by SMS or voice call. Repeat the call to resend. 2. Submit the code with [`POST .../caller-id-verification/verify`](https://pozy.ai/docs/api/verifyWhatsAppCallerId). After that, `callerIdMode` is `business` and `callerIdVerified` is `true`. Numbers bought through Pozy never need this step and get a `400` if you try. ## Common errors | Status | Cause | Fix | | --- | --- | --- | | `409` on `POST /v1/whatsapp/calls` | No active permission from the customer | Check permission and send a request first | | `422` on enable | Billing, messaging tier or country rule not met | See [What you need](#what-you-need) | | `422` on a call | Calling is off on the number, the country blocks outbound calls, or SIP credentials are missing | Enable calling or fix the destination | | `502` on a call | The call could not be connected and is marked `failed` | Try again later with a new idempotency key | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/calling`](https://pozy.ai/docs/api/getWhatsAppCallingConfig) | Get calling config for an account | | [`POST /v1/whatsapp/phone-numbers/{id}/calling`](https://pozy.ai/docs/api/enableWhatsAppCallingLegacy) | Enable calling on a number | | [`PATCH /v1/whatsapp/phone-numbers/{id}/calling`](https://pozy.ai/docs/api/updateWhatsAppCallingLegacy) | Update calling config | | [`DELETE /v1/whatsapp/phone-numbers/{id}/calling`](https://pozy.ai/docs/api/disableWhatsAppCallingLegacy) | Disable calling on a number | | [`GET /v1/whatsapp/call-permissions`](https://pozy.ai/docs/api/getWhatsAppCallPermissions) | Check call permission | | [`GET /v1/whatsapp/calls`](https://pozy.ai/docs/api/listWhatsAppCalls) | List call history for an account | | [`POST /v1/whatsapp/calls`](https://pozy.ai/docs/api/initiateWhatsAppCall) | Initiate outbound call | | [`GET /v1/whatsapp/calls/{id}`](https://pozy.ai/docs/api/getWhatsAppCall) | Get a single call | | [`GET /v1/whatsapp/calls/{id}/recording`](https://pozy.ai/docs/api/getWhatsAppCallRecording) | Get a call recording | | [`GET /v1/whatsapp/calls/estimate`](https://pozy.ai/docs/api/getWhatsAppCallEstimate) | Estimate per-minute cost | | [`GET /v1/phone-numbers/{id}/whatsapp/calling`](https://pozy.ai/docs/api/getWhatsAppCalling) | Get calling config for a number | | [`POST /v1/phone-numbers/{id}/whatsapp/calling`](https://pozy.ai/docs/api/enableWhatsAppCalling) | Enable calling on a number | | [`PATCH /v1/phone-numbers/{id}/whatsapp/calling`](https://pozy.ai/docs/api/updateWhatsAppCalling) | Update calling config | | [`DELETE /v1/phone-numbers/{id}/whatsapp/calling`](https://pozy.ai/docs/api/disableWhatsAppCalling) | Disable calling on a number | | [`POST /v1/phone-numbers/{id}/whatsapp/caller-id-verification`](https://pozy.ai/docs/api/startWhatsAppCallerIdVerification) | Start caller-ID verification for a customer-brought number | | [`POST /v1/phone-numbers/{id}/whatsapp/caller-id-verification/verify`](https://pozy.ai/docs/api/verifyWhatsAppCallerId) | Confirm the caller-ID verification code | ## Webhook events | Event | When it fires | | --- | --- | | [`call.received`](https://pozy.ai/docs/webhooks/events#call-received) | Call received. | | [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. | | [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. | | [`call.permission_request`](https://pozy.ai/docs/webhooks/events#call-permission-request) | Call permission request reply. | --- # WhatsApp broadcasts Send an approved template to a list of contacts with per-person variables, now or later, and track who received and read it. A broadcast sends one approved [template](https://pozy.ai/docs/whatsapp/templates) to many people, each as a private one-to-one message with their own name, order number or code filled in. Use it for campaigns, reminders and announcements. Pozy queues the sends, tracks each recipient through sent, delivered and read, and puts any replies in the [inbox](https://pozy.ai/docs/inbox/messages). Broadcasts work the same way on other messaging channels; the [Broadcasts guide](https://pozy.ai/docs/automations/broadcasts) covers the shared parts. This page covers what is specific to WhatsApp. ## Before you send > **Only message people who opted in:** > > WhatsApp requires that every recipient has agreed to hear from your business on WhatsApp. Sending to people who did not opt in leads to blocks and reports, which lower your number's quality rating and can get templates paused or the number restricted. Keep `isSubscribed` accurate on your [contacts](https://pozy.ai/docs/inbox/contacts) and filter on it. Three limits shape a WhatsApp broadcast: | Limit | What happens | | --- | --- | | Messaging limit tier | Meta caps the unique people a number can reach per day. A new number is on `TIER_250`. Once the cap is reached Meta stops delivering, so a list bigger than the tier will not all arrive. Check `messaging_limit_tier` with [number info](https://pozy.ai/docs/api/getWhatsAppNumberInfo) before a large send. | | Quality rating | A `RED` rating can pause templates and lower the tier. Watch `whatsapp.account.quality_updated`. | | Throughput | A coexistence number sends at most 20 messages per second. Cloud API-only numbers follow Meta's standard throughput. | Each recipient counts as one outbound message on your Pozy bill. Meta also charges its own per-template fee to your WABA ([Pricing](https://pozy.ai/docs/whatsapp/pricing)). ## Send a broadcast ### Create the draft Call [`POST /v1/broadcasts`](https://pozy.ai/docs/api/createBroadcast) with `platform: "whatsapp"`, the profile and account, a `name` for your own reference and the `template` to send. ```bash cURL curl -X POST https://api.pozy.ai/v1/broadcasts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6710c4e2b9a1f03d8e5c7a21", "accountId": "6712a9d4c0e3b58f1a7d2e64", "platform": "whatsapp", "name": "November loyalty offer", "template": { "name": "loyalty_offer", "language": "en", "components": [{ "type": "body", "parameters": [ { "type": "text", "text": "{{1}}" }, { "type": "text", "text": "{{2}}" } ] }], "variableMapping": { "1": { "field": "name" }, "2": { "field": "custom", "customValue": "BREAD10" } } }, "segmentFilters": { "tags": ["loyalty"], "isSubscribed": true } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/broadcasts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6710c4e2b9a1f03d8e5c7a21", accountId: "6712a9d4c0e3b58f1a7d2e64", platform: "whatsapp", name: "November loyalty offer", template: { name: "loyalty_offer", language: "en", components: [{ type: "body", parameters: [{ type: "text", text: "{{1}}" }, { type: "text", text: "{{2}}" }], }], variableMapping: { "1": { field: "name" }, "2": { field: "custom", customValue: "BREAD10" }, }, }, segmentFilters: { tags: ["loyalty"], isSubscribed: true }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const broadcastId = data.broadcast.id; // status "draft" ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/broadcasts", headers=H, json={ "profileId": "6710c4e2b9a1f03d8e5c7a21", "accountId": "6712a9d4c0e3b58f1a7d2e64", "platform": "whatsapp", "name": "November loyalty offer", "template": { "name": "loyalty_offer", "language": "en", "components": [{ "type": "body", "parameters": [{"type": "text", "text": "{{1}}"}, {"type": "text", "text": "{{2}}"}], }], "variableMapping": { "1": {"field": "name"}, "2": {"field": "custom", "customValue": "BREAD10"}, }, }, "segmentFilters": {"tags": ["loyalty"], "isSubscribed": True}, }, timeout=30, ) broadcast_id = resp.json()["broadcast"]["id"] ``` A template without variables only needs `name` and `language`. ### Add recipients Call [`POST /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/addBroadcastRecipients) with one of: | Field | Adds | | --- | --- | | `phones` | Numbers in E.164 (`+351912345678`). Pozy creates a contact for any number it has not seen. | | `contactIds` | Existing Pozy contacts, by contact id | | `useSegment: true` | Every contact matching the broadcast's `segmentFilters` | ```bash cURL curl -X POST "https://api.pozy.ai/v1/broadcasts/$BROADCAST_ID/recipients" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "useSegment": true }' ``` The response says how many were `added` and how many were `skipped` (duplicates, and contacts with no WhatsApp channel). ### Send now or later Start right away with [`POST /v1/broadcasts/{broadcastId}/send`](https://pozy.ai/docs/api/sendBroadcast), or pick a time with [`POST /v1/broadcasts/{broadcastId}/schedule`](https://pozy.ai/docs/api/scheduleBroadcast) and an ISO 8601 `scheduledAt`: ```bash cURL curl -X POST "https://api.pozy.ai/v1/broadcasts/$BROADCAST_ID/schedule" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledAt": "2026-11-05T09:30:00Z" }' ``` A scheduled broadcast can be stopped with [`POST /v1/broadcasts/{broadcastId}/cancel`](https://pozy.ai/docs/api/cancelBroadcast) before it starts. ## Fill variables per recipient `variableMapping` maps each numbered variable of the template body to a value. Pozy resolves it for every recipient at send time. | `field` | Becomes | | --- | --- | | `name` | The contact's name, or "there" if it is empty | | `phone` | The recipient's phone number | | `email` | The contact's email | | `company` | The contact's company | | `custom` | The fixed `customValue` you set, the same for everyone | Broadcasts fill numbered variables (`{{1}}`, `{{2}}`) only, not named ones. The number of `variableMapping` entries must match the number of variables in the template body exactly. If it does not, Meta rejects each send with code `132000` and every recipient fails. `segmentFilters.customFields` matches contact [custom fields](https://pozy.ai/docs/inbox/custom-fields) by slug, and the type must match too: `5` does not match `"5"`. ## Track delivery Each recipient moves through `pending`, `sent`, `delivered` and `read`, or ends at `failed`. Two ways to follow along: - Poll [`GET /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/getBroadcast) for totals (`sentCount`, `deliveredCount`, `readCount`, `failedCount`) and the broadcast `status` (`draft`, `scheduled`, `sending`, `completed`, `failed`, `cancelled`). - List people with [`GET /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/listBroadcastRecipients), filtered by `status`. Failed recipients carry `errorCode` and `errorExplanation` from WhatsApp. Or subscribe to `message.delivered`, `message.read` and `message.failed` and update your own records as events arrive. Replies come in as normal conversations. Because the customer wrote back, the 24-hour window is open and you can answer with free-form messages. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` "Broadcast has no recipients" on send or schedule | Nothing was added | Add recipients first | | `400` "Cannot send broadcast in sending status" | The broadcast already started or finished | Check `status` before retrying | | Many recipients `failed` with `132000` | Variable count does not match the template | Fix `variableMapping` and create a new broadcast | | Far fewer deliveries than recipients on a large list | The number's messaging limit tier was reached | Wait for Meta to raise the tier, or spread the list over several days | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/broadcasts`](https://pozy.ai/docs/api/listBroadcasts) | List broadcasts | | [`POST /v1/broadcasts`](https://pozy.ai/docs/api/createBroadcast) | Create broadcast draft | | [`GET /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/getBroadcast) | Get broadcast details | | [`PATCH /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/updateBroadcast) | Update broadcast | | [`DELETE /v1/broadcasts/{broadcastId}`](https://pozy.ai/docs/api/deleteBroadcast) | Delete broadcast | | [`POST /v1/broadcasts/{broadcastId}/send`](https://pozy.ai/docs/api/sendBroadcast) | Send broadcast now | | [`POST /v1/broadcasts/{broadcastId}/schedule`](https://pozy.ai/docs/api/scheduleBroadcast) | Schedule broadcast for later | | [`POST /v1/broadcasts/{broadcastId}/cancel`](https://pozy.ai/docs/api/cancelBroadcast) | Cancel broadcast | | [`GET /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/listBroadcastRecipients) | List broadcast recipients | | [`POST /v1/broadcasts/{broadcastId}/recipients`](https://pozy.ai/docs/api/addBroadcastRecipients) | Add recipients to a broadcast | ## Webhook events | Event | When it fires | | --- | --- | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`whatsapp.account.quality_updated`](https://pozy.ai/docs/webhooks/events#whatsapp-account-quality-updated) | WhatsApp quality rating or messaging limit changed. | --- # Business AI agent Set up Meta's AI agent on a WhatsApp number through the API, teach it about the business, test it, roll it out and hand chats back to people. Meta offers its own AI agent for WhatsApp Business numbers. When it is on, it answers customers in the chat using the business information, FAQs, websites and files you give it, follows the tone and rules you write as skills, and can call your APIs to look things up. Pozy exposes the whole setup under `/v1/accounts/{accountId}/business-agent`, so you can configure the agent for a merchant without them opening Meta's tools, except for two steps Meta keeps for the merchant. ## What only the merchant can do | Step | Where | What happens until it is done | | --- | --- | --- | | Accept Meta's Business Agent terms | The Business Agent tab in WhatsApp Manager | Every agent call returns `business_agent_terms_not_accepted` | | Attach a payment method to the Business Agent billable account | Meta's Billing Hub | The agent can be switched on, but Meta delivers none of its replies to the public. Testing with an allowlist still works. | [`GET /v1/accounts/{accountId}/business-agent`](https://pozy.ai/docs/api/getBusinessAgentStatus) reports the first under `manualSteps` (with the URL to send the merchant to). Meta does not expose billing state, so the second is listed under `unverifiedSteps` once an agent exists and you decide when to stop showing it. ## Which numbers qualify The number must be on the Cloud API, in a vertical and country Meta supports, in good standing, and not running another AI product. Meta allows one AI product per number, so a coexistence number that still has the WhatsApp Business app's own AI linked is not eligible until the merchant unlinks it in the app. `eligible` on the status read is Meta's answer, and creating an agent on an ineligible number returns `403` `business_agent_not_eligible`. ## Set it up ### Check where the merchant stands ```bash cURL curl https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "eligible": true, "termsAccepted": true, "onboarded": false, "enabled": false, "agentId": null, "settings": null, "manualSteps": [], "unverifiedSteps": [] } ``` ### Create the agent Call [`POST .../business-agent/onboard`](https://pozy.ai/docs/api/onboardBusinessAgent) once. Meta then prepares the agent, which takes about a minute. Configuration calls made before it is ready return `business_agent_not_found`, so wait and retry. ### Give it knowledge Everything the agent says comes from what you load here. | What | Call | Notes | | --- | --- | --- | | Business information | [`PUT .../business-information`](https://pozy.ai/docs/api/replaceBusinessAgentBusinessInformation) | Payment methods, return policy, how to buy, shipping, description, email, hours and address. A full replace: fields you leave out are cleared. | | FAQs | [`POST .../faqs`](https://pozy.ai/docs/api/createBusinessAgentFaq) | One specific question per entry (question up to 1,000 characters, answer up to 8,000). Answer quality drops past a few hundred entries. | | Websites | [`POST .../websites`](https://pozy.ai/docs/api/addBusinessAgentWebsite) | Meta crawls the site and recrawls it from time to time. Narrow it with included and excluded subdomains and URL patterns. Check `crawl_status` and `crawl_error`. | | Files | [`POST .../files`](https://pozy.ai/docs/api/uploadBusinessAgentFile) | PDF, DOC, DOCX, PNG and JPG (CSV and XLSX when Meta enables it for the account), up to 100 MB | None of these create calls are idempotent. A retry adds a second entry, so check the list before retrying after a timeout. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent/faqs \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "question": "Can I order a cake for the same day?", "answer": "Same-day cakes are available until 11am for pickup after 4pm. For delivery, order a day ahead." }' ``` ```ts Node const accountId = "6712a9d4c0e3b58f1a7d2e64"; const res = await fetch(`https://api.pozy.ai/v1/accounts/${accountId}/business-agent/faqs`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ question: "Can I order a cake for the same day?", answer: "Same-day cakes are available until 11am for pickup after 4pm. For delivery, order a day ahead.", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} base = "https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent" requests.post(f"{base}/faqs", headers=H, timeout=30, json={ "question": "Can I order a cake for the same day?", "answer": "Same-day cakes are available until 11am for pickup after 4pm. For delivery, order a day ahead.", }).raise_for_status() ``` ### Give it a voice - **Skills** ([`POST .../skills`](https://pozy.ai/docs/api/createBusinessAgentSkill)) are written instructions in the brand's voice: how to greet, what to never promise, when to suggest a product. Each skill is up to 20,000 characters and reads `pending_review` until Meta's content review passes it. - **UI skills** ([`POST .../ui-skills`](https://pozy.ai/docs/api/createBusinessAgentUiSkill)) tell the agent when to send a rich component (a link button, image, carousel, list, reply buttons, location or a [Flow](https://pozy.ai/docs/whatsapp/flows)) and what to put in it. - **Never-say phrases** live in the settings (`never_say_phrases`) and replace the whole list on each update. ### Connect your systems (optional) A connector lets the agent call your API mid-conversation, for example to check an order status. Create it with a `base_url` and an `auth_type` (`OAUTH2_CLIENT_CREDENTIALS`, `API_KEY` or `NONE`) using [`POST .../connectors`](https://pozy.ai/docs/api/createBusinessAgentConnector), then define each operation as a tool with [`POST .../connectors/{connectorId}/tools`](https://pozy.ai/docs/api/createBusinessAgentConnectorTool). Run a tool once with [`.../run`](https://pozy.ai/docs/api/runBusinessAgentConnectorTool) to check it before the agent relies on it. Rotate secrets with [`.../credentials`](https://pozy.ai/docs/api/setBusinessAgentConnectorCredentials). For an MCP server connector, [`.../refresh-tools`](https://pozy.ai/docs/api/refreshBusinessAgentConnectorTools) rediscovers its tools. [`.../logs`](https://pozy.ai/docs/api/getBusinessAgentConnectorLogs) shows failures from the last 7 days. ### Test it privately [`POST .../test-messages`](https://pozy.ai/docs/api/sendBusinessAgentTestMessage) runs a message through the full agent in Meta's sandbox. No customer is involved and no tokens are billed. Pass the returned `conversation_id` back to continue the same thread. Meta limits how many test messages a number can send per hour. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent/test-messages \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Do you deliver to Cascais on Sundays?" }' ``` The response holds `agent_response`, plus `handoff_reason` or `no_response_reason` when the agent chose not to answer, and any `quick_replies` it offered. For a broader check, [`POST .../evals`](https://pozy.ai/docs/api/startBusinessAgentEvalRun) simulates a set of scenarios and scores them. It answers `202` with a job id to poll with [`GET .../evals`](https://pozy.ai/docs/api/readBusinessAgentEvals). ### Roll it out Turn the agent on with [`PATCH .../settings`](https://pozy.ai/docs/api/updateBusinessAgentSettings). Start with a few real numbers on an allowlist, then open it to everyone. ```bash cURL # 1. Allow a test phone curl -X POST https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent/allowlist \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "consumer_phone_number": "+351912345678" }' # 2. Switch on for allowlisted people only curl -X PATCH https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/business-agent/settings \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rollout": { "enabled": true }, "ai_audience": "ALLOWLISTED_ONLY", "handoff": { "enabled": true, "message_selection": "CUSTOM", "message": "Let me get someone from the shop to help you." } }' ``` When you are happy, set `ai_audience` to `EVERYONE`. That needs the payment method from the merchant steps above. ## Settings | Setting | Effect | | --- | --- | | `rollout.enabled` | `true` turns the agent on for new conversations. `false` stops it on every thread. | | `ai_audience` | `ALLOWLISTED_ONLY` or `EVERYONE` | | `handoff` | What the agent says when it passes a chat to a person (`DEFAULT`, `AGENT` or `CUSTOM` message, up to 1,024 characters) | | `followup` | A nudge sent after a quiet period: 5 minutes to 24 hours, or `0` for off | | `never_say_phrases` | Phrases the agent must never use | Settings update partially: fields you leave out keep their value. ## Work alongside the agent While the agent holds a conversation, Pozy still sees everything: - Customer messages arrive as `message.received` with `metadata.standby: true`. - The agent's replies arrive as `message.sent` with `source: "meta_business_agent"`. Sending any message from Pozy takes the conversation back from the agent. If you run your own bot on the same number, skip messages marked `standby` or you will interrupt the agent on every turn. The agent also hands over by itself when it is unsure or the customer asks for a person. To give a conversation back to the agent without sending anything, call [`POST /v1/inbox/conversations/{conversationId}/thread-control`](https://pozy.ai/docs/api/setConversationThreadControl) with `action: "release"`. Every change of control fires `conversation.control_changed`. ## Tell the agent about events When something happens in your systems that the customer should hear about (an order shipped, a document approved), send it with [`POST .../business-agent/events`](https://pozy.ai/docs/api/sendBusinessAgentEvent): the customer's number in `to`, a `type`, a `description` and a `payload`. The agent writes the message. The customer must already have a conversation with the number. The call answers `202` with an event id; check the outcome with [`GET .../events/{eventId}`](https://pozy.ai/docs/api/getBusinessAgentEvent). ## Costs Meta bills agent replies by tokens on the merchant's Business Agent billable account, separately from Pozy. Meta's own figure is roughly 4 to 5 US cents per message. Cap the spend with [`PUT .../budget`](https://pozy.ai/docs/api/replaceBusinessAgentBudget): each budget is a `max_budget` of `token` or `ai_turn` units over `one_day`, `seven_days`, `fourteen_days` or `thirty_days`. Sending the list replaces all budgets, and an empty list means no cap. When a cap is hit the agent finishes its turn, stops answering and hands the thread to a person until the window rolls over. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/business-agent`](https://pozy.ai/docs/api/getBusinessAgentStatus) | Get agent setup status | | [`POST /v1/accounts/{accountId}/business-agent/onboard`](https://pozy.ai/docs/api/onboardBusinessAgent) | Create the agent | | [`GET /v1/accounts/{accountId}/business-agent/settings`](https://pozy.ai/docs/api/listBusinessAgentSettings) | List agent settings | | [`PATCH /v1/accounts/{accountId}/business-agent/settings`](https://pozy.ai/docs/api/updateBusinessAgentSettings) | Update agent settings | | [`GET /v1/accounts/{accountId}/business-agent/allowlist`](https://pozy.ai/docs/api/listBusinessAgentAllowlist) | List allowlisted consumers | | [`POST /v1/accounts/{accountId}/business-agent/allowlist`](https://pozy.ai/docs/api/addBusinessAgentAllowlistEntry) | Allowlist a consumer | | [`DELETE /v1/accounts/{accountId}/business-agent/allowlist/{entryId}`](https://pozy.ai/docs/api/removeBusinessAgentAllowlistEntry) | Remove an allowlisted consumer | | [`GET /v1/accounts/{accountId}/business-agent/business-information`](https://pozy.ai/docs/api/getBusinessAgentBusinessInformation) | Get business information | | [`PUT /v1/accounts/{accountId}/business-agent/business-information`](https://pozy.ai/docs/api/replaceBusinessAgentBusinessInformation) | Replace business information | | [`DELETE /v1/accounts/{accountId}/business-agent/business-information`](https://pozy.ai/docs/api/resetBusinessAgentBusinessInformation) | Reset business information | | [`GET /v1/accounts/{accountId}/business-agent/faqs`](https://pozy.ai/docs/api/listBusinessAgentFaqs) | List FAQs | | [`POST /v1/accounts/{accountId}/business-agent/faqs`](https://pozy.ai/docs/api/createBusinessAgentFaq) | Create a FAQ | | [`GET /v1/accounts/{accountId}/business-agent/faqs/{faqId}`](https://pozy.ai/docs/api/getBusinessAgentFaq) | Get a FAQ | | [`PUT /v1/accounts/{accountId}/business-agent/faqs/{faqId}`](https://pozy.ai/docs/api/updateBusinessAgentFaq) | Update a FAQ | | [`DELETE /v1/accounts/{accountId}/business-agent/faqs/{faqId}`](https://pozy.ai/docs/api/deleteBusinessAgentFaq) | Delete a FAQ | | [`GET /v1/accounts/{accountId}/business-agent/websites`](https://pozy.ai/docs/api/listBusinessAgentWebsites) | List crawled websites | | [`POST /v1/accounts/{accountId}/business-agent/websites`](https://pozy.ai/docs/api/addBusinessAgentWebsite) | Add a website to crawl | | [`GET /v1/accounts/{accountId}/business-agent/websites/{websiteId}`](https://pozy.ai/docs/api/getBusinessAgentWebsite) | Get a crawled website | | [`PUT /v1/accounts/{accountId}/business-agent/websites/{websiteId}`](https://pozy.ai/docs/api/updateBusinessAgentWebsite) | Update a crawled website | | [`DELETE /v1/accounts/{accountId}/business-agent/websites/{websiteId}`](https://pozy.ai/docs/api/deleteBusinessAgentWebsite) | Remove a crawled website | | [`GET /v1/accounts/{accountId}/business-agent/files`](https://pozy.ai/docs/api/listBusinessAgentFiles) | List knowledge files | | [`POST /v1/accounts/{accountId}/business-agent/files`](https://pozy.ai/docs/api/uploadBusinessAgentFile) | Upload a knowledge file | | [`GET /v1/accounts/{accountId}/business-agent/files/{fileId}`](https://pozy.ai/docs/api/getBusinessAgentFile) | Get a knowledge file | | [`DELETE /v1/accounts/{accountId}/business-agent/files/{fileId}`](https://pozy.ai/docs/api/deleteBusinessAgentFile) | Delete a knowledge file | | [`GET /v1/accounts/{accountId}/business-agent/skills`](https://pozy.ai/docs/api/listBusinessAgentSkills) | List skills | | [`POST /v1/accounts/{accountId}/business-agent/skills`](https://pozy.ai/docs/api/createBusinessAgentSkill) | Create a skill | | [`GET /v1/accounts/{accountId}/business-agent/skills/{skillId}`](https://pozy.ai/docs/api/getBusinessAgentSkill) | Get a skill | | [`PUT /v1/accounts/{accountId}/business-agent/skills/{skillId}`](https://pozy.ai/docs/api/updateBusinessAgentSkill) | Update a skill | | [`DELETE /v1/accounts/{accountId}/business-agent/skills/{skillId}`](https://pozy.ai/docs/api/deleteBusinessAgentSkill) | Delete a skill | | [`GET /v1/accounts/{accountId}/business-agent/ui-skills`](https://pozy.ai/docs/api/listBusinessAgentUiSkills) | List UI skills | | [`POST /v1/accounts/{accountId}/business-agent/ui-skills`](https://pozy.ai/docs/api/createBusinessAgentUiSkill) | Create a UI skill | | [`GET /v1/accounts/{accountId}/business-agent/ui-skills/{uiSkillId}`](https://pozy.ai/docs/api/getBusinessAgentUiSkill) | Get a UI skill | | [`PUT /v1/accounts/{accountId}/business-agent/ui-skills/{uiSkillId}`](https://pozy.ai/docs/api/updateBusinessAgentUiSkill) | Update a UI skill | | [`DELETE /v1/accounts/{accountId}/business-agent/ui-skills/{uiSkillId}`](https://pozy.ai/docs/api/deleteBusinessAgentUiSkill) | Delete a UI skill | | [`GET /v1/accounts/{accountId}/business-agent/connectors`](https://pozy.ai/docs/api/listBusinessAgentConnectors) | List connectors | | [`POST /v1/accounts/{accountId}/business-agent/connectors`](https://pozy.ai/docs/api/createBusinessAgentConnector) | Create a connector | | [`GET /v1/accounts/{accountId}/business-agent/connectors/{connectorId}`](https://pozy.ai/docs/api/getBusinessAgentConnector) | Get a connector | | [`PUT /v1/accounts/{accountId}/business-agent/connectors/{connectorId}`](https://pozy.ai/docs/api/updateBusinessAgentConnector) | Update a connector | | [`DELETE /v1/accounts/{accountId}/business-agent/connectors/{connectorId}`](https://pozy.ai/docs/api/deleteBusinessAgentConnector) | Delete a connector | | [`POST /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/credentials`](https://pozy.ai/docs/api/setBusinessAgentConnectorCredentials) | Set connector credentials | | [`POST /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/refresh-tools`](https://pozy.ai/docs/api/refreshBusinessAgentConnectorTools) | Refresh MCP connector tools | | [`GET /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/logs`](https://pozy.ai/docs/api/getBusinessAgentConnectorLogs) | Get connector failure logs | | [`GET /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools`](https://pozy.ai/docs/api/listBusinessAgentConnectorTools) | List connector tools | | [`POST /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools`](https://pozy.ai/docs/api/createBusinessAgentConnectorTool) | Create a connector tool | | [`GET /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}`](https://pozy.ai/docs/api/getBusinessAgentConnectorTool) | Get a connector tool | | [`PUT /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}`](https://pozy.ai/docs/api/updateBusinessAgentConnectorTool) | Update a connector tool | | [`DELETE /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}`](https://pozy.ai/docs/api/deleteBusinessAgentConnectorTool) | Delete a connector tool | | [`POST /v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}/run`](https://pozy.ai/docs/api/runBusinessAgentConnectorTool) | Run a connector tool once | | [`GET /v1/accounts/{accountId}/business-agent/budget`](https://pozy.ai/docs/api/getBusinessAgentBudget) | Get usage budgets | | [`PUT /v1/accounts/{accountId}/business-agent/budget`](https://pozy.ai/docs/api/replaceBusinessAgentBudget) | Replace usage budgets | | [`POST /v1/accounts/{accountId}/business-agent/test-messages`](https://pozy.ai/docs/api/sendBusinessAgentTestMessage) | Send a test message | | [`POST /v1/accounts/{accountId}/business-agent/events`](https://pozy.ai/docs/api/sendBusinessAgentEvent) | Send a business event | | [`GET /v1/accounts/{accountId}/business-agent/events/{eventId}`](https://pozy.ai/docs/api/getBusinessAgentEvent) | Get a business event status | | [`GET /v1/accounts/{accountId}/business-agent/evals`](https://pozy.ai/docs/api/readBusinessAgentEvals) | Read evaluation data | | [`POST /v1/accounts/{accountId}/business-agent/evals`](https://pozy.ai/docs/api/startBusinessAgentEvalRun) | Start an evaluation run | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | --- # WhatsApp sandbox Test WhatsApp sends, replies and webhooks from a shared Pozy number before you buy or connect a number of your own. Free. The sandbox lets you try WhatsApp end to end without a number, a WABA or a Meta review. Pozy runs one shared WhatsApp number for testing. You prove you own a test phone by replying to it once, and for the next 7 days that phone is a normal conversation in your inbox: you can send it a template, chat with it freely and receive every webhook. It costs nothing. When you are ready for real traffic, [get a number of your own](https://pozy.ai/docs/whatsapp/phone-numbers). ## Sandbox limits | Property | Value | | --- | --- | | Number | One shared Pozy number, returned under `sandbox` by `GET /v1/phone-numbers` | | Templates | One, locked: `sandbox_start` in `en`, no variables | | Free-form messages | Yes, inside the 24-hour window each reply opens | | Test phones | 1 per user at a time | | Daily cap | 50 messages and 5 distinct recipients per 24 hours | | Activation link validity | 24 hours while pending | | Session length | 7 days after activation | | Price | Free: no number charge and no message charge | Because the number is shared, a phone can only be messaged after its owner has replied to it from WhatsApp. That stops anyone from using the sandbox to reach strangers. ## Try it ### Find the sandbox number Call [`GET /v1/phone-numbers`](https://pozy.ai/docs/api/listPhoneNumbers). The `sandbox` object holds the number, the `accountId` you send from and the locked template. ```json { "numbers": [], "connected": [], "sandbox": { "phoneNumber": "+14155550100", "accountId": "6716e3b8a4c7f92d5e1b6c08", "template": { "name": "sandbox_start", "language": "en" }, "isSandbox": true } } ``` ### Activate your test phone Call [`POST /v1/whatsapp/sandbox/sessions`](https://pozy.ai/docs/api/createWhatsAppSandboxSession) with the phone in E.164. Pozy creates a `pending` session and immediately sends `sandbox_start` to that phone. ```bash cURL curl -X POST https://api.pozy.ai/v1/whatsapp/sandbox/sessions \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+351912345678" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/sandbox/sessions", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ phone: "+351912345678" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.session.status, data.sandboxNumber); // "pending" ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/whatsapp/sandbox/sessions", headers=H, json={"phone": "+351912345678"}, timeout=30, ) print(resp.json()["session"]["status"]) ``` Posting the same phone again is safe: it resends the template. ### Reply from the phone Open WhatsApp on the test phone and reply to the message, or tap its button. Any reply flips the session to `active`. Watch for it with a `message.received` [webhook](https://pozy.ai/docs/webhooks), or poll [`GET /v1/whatsapp/sandbox/sessions`](https://pozy.ai/docs/api/listWhatsAppSandboxSessions) until `status` is `active`. ### Send messages The phone is now an ordinary WhatsApp conversation. Send the locked template with [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation), using the sandbox `accountId` and the phone as digits only: ```bash cURL curl -X POST https://api.pozy.ai/v1/inbox/conversations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6716e3b8a4c7f92d5e1b6c08", "participantId": "351912345678", "templateName": "sandbox_start", "templateLanguage": "en", "templateParams": [] }' ``` Each reply from the phone opens a 24-hour window. Inside it, [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) sends free-form text, attachments and interactive messages, exactly as with a real number. ## Switch to another test phone You can test one phone at a time. Revoke the current session with [`DELETE /v1/whatsapp/sandbox/sessions/{sessionId}`](https://pozy.ai/docs/api/deleteWhatsAppSandboxSession), then activate the new phone. Messages already exchanged stay in the inbox; revoking only blocks new sends. > **Keep sandbox events apart:** > > To send sandbox traffic to its own endpoint, create a [webhook](https://pozy.ai/docs/webhooks) scoped to the sandbox `accountId`. Note that an account-scoped endpoint no longer receives events that belong to no account, such as `whatsapp.number.*`. ## What the sandbox cannot do - Send to more than one phone at a time, or to anyone who has not replied. - Send any template other than `sandbox_start`, or use templates from your own WhatsApp Manager. - Carry production traffic. It exists to test your integration, not to message customers. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` "You can only test one phone at a time" | Another phone has an active or pending session. The message names it. | Revoke that session first | | `400` "This phone is not activated for sandbox sends" | The recipient has not replied yet | Finish activation for that phone | | `400` "Cannot activate the sandbox number itself" | You passed the sandbox number as `phone` | Use your own WhatsApp number | | `400` "Could not send the activation message" | Meta refused the send, usually because the phone is not on WhatsApp. Meta's message is included. | Check the number | | `rate_limited` | 50 messages or 5 recipients in 24 hours | Wait for the rolling window, or use your own number | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers`](https://pozy.ai/docs/api/listPhoneNumbers) | List phone numbers | | [`GET /v1/whatsapp/sandbox/sessions`](https://pozy.ai/docs/api/listWhatsAppSandboxSessions) | List your sandbox sessions | | [`POST /v1/whatsapp/sandbox/sessions`](https://pozy.ai/docs/api/createWhatsAppSandboxSession) | Start a sandbox activation | | [`DELETE /v1/whatsapp/sandbox/sessions/{sessionId}`](https://pozy.ai/docs/api/deleteWhatsAppSandboxSession) | Revoke a sandbox session | | [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation | | [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. | --- # WhatsApp pricing Which WhatsApp costs Pozy bills, which ones Meta bills to your WhatsApp Business Account, and how to read Meta's numbers through the API. WhatsApp has two bills. Pozy bills for the number, the connection and what you send through Pozy. Meta bills your WhatsApp Business Account (WABA) directly for template deliveries, outbound call minutes and its AI agent. Pozy never resells or marks up Meta's fees, and they never appear on your Pozy invoice. ## What Pozy bills | Item | Price | | --- | --- | | The connected WhatsApp number | Counts as one connected account, like any other platform ([connected account pricing](https://pozy.ai/docs/billing)) | | A number bought through Pozy | From $3 per number per month, by country and number type. The price shows before you buy and is fixed at that rate. Charged at activation and on the 1st of each month. | | Messages you send | Each message counts as one outbound message, including one per broadcast recipient. The first 10,000 each month are free across the workspace, then $1 per 10,000. | | Calls | The connection leg, per minute by destination country, from $0.01 per minute. Calls routed to a SIP or WebSocket destination have no phone-network leg. | | Call recording | $0.004 per minute, only when recording is on | Templates, flows, groups, broadcasts, sequences and the inbox have no extra Pozy fee beyond the outbound message meter. **Every usage price on one table** | What | Price | | --- | --- | | Outbound messages (inbox replies, broadcasts, sequences; not X DMs or SMS) | First 10,000 each month free, then $1 per 10,000 | | Managed ads (running or in review) | First 500 free, then $0.01 per active ad per month | | X post | $0.015 per post | | X post containing a link | $0.20 per post | | X reads (analytics, lookups) | $0.005 per resource | | X DMs | $0.01 per DM read, $0.015 per DM sent | | Phone numbers | From $3 per number per month, by country | | Calls | From $0.01 per minute by destination. Recording $0.004 per minute, transcription $0.03 per minute | | Branded calling | $0.10 per branded call | | SMS | From $0.008 per segment by destination | | US 10DLC registration | $9 one-time brand fee, then $20 per month per campaign ($4 for a sole proprietor) | | RCS agent | $150 setup, then $160 per month in the US or $75 elsewhere, plus $750 on US carrier approval | | Creator payouts | 4% of each transfer, minimum $0.50 | | Support assistant | At cost per answered question, capped at $100 per month | ## What Meta bills | Item | Price | Billed to | | --- | --- | --- | | Free-form messages inside the 24-hour window | Free | Nobody | | Utility templates sent inside the 24-hour window | Free | Nobody | | Template deliveries outside the window | Per delivered message, by template category (`MARKETING`, `UTILITY`, `AUTHENTICATION`) and the recipient's country | Your WABA | | Inbound calls | Free | Nobody | | Outbound calls | Per minute, by the recipient's country | Your WABA | | [Business AI agent](https://pozy.ai/docs/whatsapp/business-agent) replies | Per token, roughly 4 to 5 US cents per message | The merchant's Business Agent billable account | Meta publishes its own rate cards by country. Use [`GET /v1/whatsapp/calls/estimate`](https://pozy.ai/docs/api/getWhatsAppCallEstimate) to see both legs of a call before you dial. > **Meta needs its own payment method:** > > Add a payment method to the WABA in Meta Business Suite. Without one, Meta stops delivering templates and outbound calls once its free allowance runs out, even if your Pozy account is in good standing. ## The number you send from | Number | Pozy number fee | Meta fees | Notes | | --- | --- | --- | --- | | The shared [sandbox](https://pozy.ai/docs/whatsapp/sandbox) number | None | None | One locked template and one test phone, for testing only | | A number bought through Pozy | From $3 per month | Yes | US numbers are ready in seconds. Other countries may need a one-time [KYC form](https://pozy.ai/docs/telephony/kyc). The same number can also make calls and send SMS. | | Your own number | None | Yes | Connected through Embedded Signup or a Meta token | Buying a number needs a payment method on the Pozy workspace. Without one the purchase, and the WhatsApp connection itself, return `402` `payment_required`. ## See what Meta is charging [`GET /v1/whatsapp/pricing-analytics`](https://pozy.ai/docs/api/getWhatsAppPricingAnalytics) returns the message volume and approximate cost Meta reports for one number. Use it to show a merchant what they are spending on Meta's side. ```bash cURL curl "https://api.pozy.ai/v1/whatsapp/pricing-analytics?accountId=6712a9d4c0e3b58f1a7d2e64&start=2026-10-01&end=2026-10-31&granularity=DAILY&dimensions=PRICING_CATEGORY" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/whatsapp/pricing-analytics?accountId=6712a9d4c0e3b58f1a7d2e64&start=2026-10-01&end=2026-10-31&granularity=DAILY&dimensions=PRICING_CATEGORY", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const p of data.dataPoints) console.log(p.start, p.pricingCategory, p.volume, p.cost); ``` ```python Python import os import requests resp = requests.get( "https://api.pozy.ai/v1/whatsapp/pricing-analytics", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={ "accountId": "6712a9d4c0e3b58f1a7d2e64", "start": "2026-10-01", "end": "2026-10-31", "granularity": "DAILY", "dimensions": "PRICING_CATEGORY", }, timeout=30, ) for p in resp.json()["dataPoints"]: print(p["start"], p["pricingCategory"], p["volume"], p["cost"]) ``` | Parameter | Values | | --- | --- | | `granularity` | `HALF_HOUR`, `DAILY` or `MONTHLY` | | `dimensions` | Any of `COUNTRY`, `PHONE`, `PRICING_CATEGORY`, `PRICING_TYPE`, `TIER`, comma-separated | | Filters | `pricingCategories`, `pricingTypes`, `countryCodes`, `metricTypes` | Meta's figures are approximate and can lag behind real traffic. Meta's own invoice is the final word. ## Ways to keep Meta costs down - Reply inside the 24-hour window whenever you can. Free-form replies are free on Meta's side. - File templates under the honest category. Utility templates usually cost less than marketing ones, and Meta reclassifies templates that do not match. - Set a short delivery window on time-sensitive templates (`message_send_ttl_seconds`), so a code that could not be delivered in time is dropped instead of delivered (and charged) after it has expired. - Cap the business agent with a [budget](https://pozy.ai/docs/whatsapp/business-agent#costs). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/whatsapp/pricing-analytics`](https://pozy.ai/docs/api/getWhatsAppPricingAnalytics) | Get pricing analytics | | [`GET /v1/whatsapp/calls/estimate`](https://pozy.ai/docs/api/getWhatsAppCallEstimate) | Estimate per-minute cost | | [`GET /v1/whatsapp/calls`](https://pozy.ai/docs/api/listWhatsAppCalls) | List call history for an account | --- # Phone numbers for WhatsApp Buy a WhatsApp-ready number, bring your own, verify and register it with Meta, check that it is alive and follow its lifecycle events. Every WhatsApp account sits on a phone number. You can buy one through Pozy, which arrives already verified with Meta and can also make calls and send SMS, or connect a number you already own. This page covers the WhatsApp side of numbers: getting one ready, keeping it registered and knowing when it stops working. Searching inventory, country rules, KYC and porting are in [Phone, SMS & voice](https://pozy.ai/docs/telephony). ## Buy a number for WhatsApp Call [`POST /v1/phone-numbers/purchase`](https://pozy.ai/docs/api/purchasePhoneNumber) with the profile the number should belong to. `connectWhatsapp` is `true` by default, so the purchase hands straight over to [WhatsApp signup](https://pozy.ai/docs/whatsapp#connect-with-embedded-signup), where the new number appears as **Verified** in Meta's picker. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/purchase \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6710c4e2b9a1f03d8e5c7a21", "country": "US", "numberType": "local", "areaCode": "415", "wantsWhatsapp": true, "purchaseIntentId": "bakery-main-line" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/purchase", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6710c4e2b9a1f03d8e5c7a21", country: "US", numberType: "local", areaCode: "415", wantsWhatsapp: true, purchaseIntentId: "bakery-main-line", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/purchase", headers=H, json={ "profileId": "6710c4e2b9a1f03d8e5c7a21", "country": "US", "numberType": "local", "areaCode": "415", "wantsWhatsapp": True, "purchaseIntentId": "bakery-main-line", }, timeout=30, ) print(resp.json()) ``` `purchaseIntentId` makes the purchase safe to retry: sending the same id again returns the number you already bought (`status: "already_purchased"`) instead of buying a second one. | Country type | What happens | | --- | --- | | Instant (the US and others) | The number is ready in seconds | | Regulated | A one-time [KYC form](https://pozy.ai/docs/telephony/kyc) first. The number activates in 1 to 3 business days and `whatsapp.number.activated` fires when it is ready. | A number needs a payment method on the workspace. Without one the purchase returns `402` `payment_required`. Pricing is per number per month ([WhatsApp pricing](https://pozy.ai/docs/whatsapp/pricing)). > **Older paths:** > > The `/v1/whatsapp/phone-numbers/*` purchase, search and KYC routes still work but are deprecated. Use the `/v1/phone-numbers/*` routes, which take the same inputs. ### Add WhatsApp to a number you bought for calls or SMS If a Pozy number was bought without WhatsApp, start Meta's verification with [`POST /v1/phone-numbers/{id}/whatsapp/request-code`](https://pozy.ai/docs/api/requestPhoneNumberWhatsAppCode) (`method` `SMS` or `VOICE`). Pozy receives the code on the number itself, so you do not have to relay it. Then connect the number through signup as usual. If Meta refuses the number for WhatsApp, a number that is already live keeps working for calls and SMS and the call returns `409` `number_not_whatsapp_eligible`. A number that was never live is swapped for an eligible one at no cost, and the response says `replaced: true` with the `newPhoneNumber`. ### Move a number to another profile A Pozy number belongs to one profile, and connecting it from any other profile returns `409`. To re-home it, call [`PATCH /v1/whatsapp/phone-numbers/{id}/profile`](https://pozy.ai/docs/api/moveWhatsAppNumberToProfile) with the new `profileId`. That moves the number and every account built on it (WhatsApp, calls, SMS) together. `id` is the number id from `GET /v1/phone-numbers`, not an account id. ## Bring your own number A number on your own carrier connects through [Embedded Signup or a Meta token](https://pozy.ai/docs/whatsapp#pick-the-kind-of-number). It works for WhatsApp messages and calls, but Pozy cannot add SMS or phone-network calling to it, because there is no Pozy line behind it. `GET /v1/phone-numbers` lists it under `connected`, not `numbers`. Meta occasionally asks a number you brought from another provider to verify again. When that happens: 1. Request a code with [`POST /v1/accounts/{accountId}/whatsapp/request-code`](https://pozy.ai/docs/api/requestWhatsAppVerificationCode) (`SMS` or `VOICE`). The code goes to the business's own phone, so someone there has to read it. You can request one every 10 minutes, and Meta adds its own cooldown. 2. Submit it with [`POST /v1/accounts/{accountId}/whatsapp/verify-code`](https://pozy.ai/docs/api/verifyWhatsAppNumber). 3. Finish with [`POST /v1/accounts/{accountId}/whatsapp/register`](https://pozy.ai/docs/api/registerWhatsAppNumber). Verifying alone does not put the number back on the Cloud API. ## Register a number that has a PIN If the number has two-step verification turned on, the connect flow cannot register it on its own and Meta rejects the registration (error `133005`). The account still looks connected, but every send fails with `(#200) You do not have the necessary permission`. Run the registration again with the right PIN. Pozy uses the PIN for this call only and does not store it. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6712a9d4c0e3b58f1a7d2e64/whatsapp/register \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pin": "205718" }' ``` A `422` means Meta still refused, most often a wrong PIN. Check it in WhatsApp Manager and try again. ## Check that a number is alive A WhatsApp channel can die without much warning, for example when a coexistence number is disconnected from the phone. Do not rely on the absence of an `account.disconnected` event; check directly. | Check | What it tells you | | --- | --- | | [`GET /v1/whatsapp/number-info`](https://pozy.ai/docs/api/getWhatsAppNumberInfo) | A live read from Meta. `200` means Meta is serving the number. `400` with Meta error 100, subcode 33 means it is gone until you reconnect. | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Runs the same live read and reports it as `platformConnection.status`: `connected`, `disconnected` or `unknown` (the read failed for another reason, such as a timeout). A valid token does not mean the channel is alive. | | [`GET /v1/whatsapp/business-profile`](https://pozy.ai/docs/api/getWhatsAppBusinessProfile) | Not a liveness check. It can return a full profile for a dead number. | | `platformStatus` in [`GET /v1/accounts`](https://pozy.ai/docs/api/listAccounts) | A stored value refreshed by periodic checks. It can be hours or days old. Compare it with `metadata.metaStatusCheckedAt` before acting on it. | Health also reports `messagingRestriction` when Meta refuses sends for a reason other than the channel itself: | Meta code | Reason | | --- | --- | | `131042` | A payment problem on the WABA | | `131031` | The WhatsApp Business Account is locked | | `368` | A policy block | The restriction appears on the first refused send and clears on the next delivered message. ## Number lifecycle Numbers bought through Pozy move through statuses, and Pozy sends an event on each step. The event names start with `whatsapp.number.` for historical reasons, but they fire for every Pozy number, including ones used only for calls or SMS. | Status | Reached by | Event | | --- | --- | --- | | `pending_regulatory` | KYC submitted in a regulated country | `whatsapp.number.kyc_submitted` (hosted KYC links only) | | `pending_regulatory` (blocked) | The regulator wants more information or an ID check | `whatsapp.number.action_required`, `whatsapp.number.verification_required` | | `regulatory_declined` | The regulator declined the order. You are not billed. Fix and resubmit with [remediation](https://pozy.ai/docs/api/remediatePhoneNumber). | `whatsapp.number.declined` | | `active` | Setup finished; ready to connect | `whatsapp.number.activated` | | `suspended` | For example a failed payment (`reason: "payment_failed"`) | `whatsapp.number.suspended` | | `active` again | The issue was fixed | `whatsapp.number.reactivated` | | `released` | Released by you or after an unresolved suspension. Final. | `whatsapp.number.released` | Numbers in instant countries skip the review states and go straight to `active`. ## Release a number [`DELETE /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/releasePhoneNumber) releases a number you bought. It disconnects the WhatsApp account on it, stops the monthly charge and makes the number unusable. This cannot be undone. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers`](https://pozy.ai/docs/api/listPhoneNumbers) | List phone numbers | | [`POST /v1/phone-numbers/purchase`](https://pozy.ai/docs/api/purchasePhoneNumber) | Purchase phone number | | [`GET /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/getPhoneNumber) | Get phone number | | [`DELETE /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/releasePhoneNumber) | Release phone number | | [`POST /v1/phone-numbers/{id}/whatsapp/request-code`](https://pozy.ai/docs/api/requestPhoneNumberWhatsAppCode) | Request the WhatsApp verification code for a number | | [`PATCH /v1/whatsapp/phone-numbers/{id}/profile`](https://pozy.ai/docs/api/moveWhatsAppNumberToProfile) | Move a number to another profile | | [`GET /v1/whatsapp/number-info`](https://pozy.ai/docs/api/getWhatsAppNumberInfo) | Get number status | | [`POST /v1/accounts/{accountId}/whatsapp/request-code`](https://pozy.ai/docs/api/requestWhatsAppVerificationCode) | Request a Meta re-verification code for a BYO WhatsApp number | | [`POST /v1/accounts/{accountId}/whatsapp/verify-code`](https://pozy.ai/docs/api/verifyWhatsAppNumber) | Verify the Meta re-verification code for a BYO WhatsApp number | | [`POST /v1/accounts/{accountId}/whatsapp/register`](https://pozy.ai/docs/api/registerWhatsAppNumber) | Register a connected WhatsApp number on the Cloud API | | [`GET /v1/accounts/{accountId}/health`](https://pozy.ai/docs/api/getAccountHealth) | Check account health | ## Webhook events | Event | When it fires | | --- | --- | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`whatsapp.number.activated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-activated) | WhatsApp number activated. | | [`whatsapp.number.declined`](https://pozy.ai/docs/webhooks/events#whatsapp-number-declined) | WhatsApp number declined. | | [`whatsapp.number.action_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-action-required) | WhatsApp number action required. | | [`whatsapp.number.verification_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-verification-required) | WhatsApp number verification-required. | | [`whatsapp.number.suspended`](https://pozy.ai/docs/webhooks/events#whatsapp-number-suspended) | WhatsApp number suspended. | | [`whatsapp.number.reactivated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-reactivated) | WhatsApp number reactivated. | | [`whatsapp.number.released`](https://pozy.ai/docs/webhooks/events#whatsapp-number-released) | WhatsApp number released. | | [`whatsapp.number.kyc_submitted`](https://pozy.ai/docs/webhooks/events#whatsapp-number-kyc-submitted) | WhatsApp number KYC submitted. | | [`phone_number.stock_available`](https://pozy.ai/docs/webhooks/events#phone-number-stock-available) | Phone-number stock available. | --- # Phone numbers Buy or port real phone numbers, then turn on calls, SMS and WhatsApp on the same number with one API. A Pozy phone number is a real number on the public phone network. You buy it (or port one you already own), and it becomes the anchor for calls, texts and WhatsApp. One number can do all three, or only the one you need. This section covers getting and running numbers, texting from them, calling from them and showing your brand when you call. ## What a number can do | Capability | How it works | Where | | --- | --- | --- | | Calls | On by default as soon as the number is active. Route inbound calls to a phone, a SIP address or an AI agent. Place outbound calls from the API or a browser. | Every country Pozy sells numbers in | | SMS and MMS | Turn on per number. US numbers need a carrier registration before they can send, which is not available yet. | SMS-capable countries and numbers only | | WhatsApp | Connect the number to a WhatsApp Business account. Any type except toll-free. | Every country | | Branded calling | Not available yet. Show your business name, logo and call reason on the callee's phone. | US numbers calling US mobiles | There is no separate "WhatsApp number" or "SMS number". Features hang off the number as sub-resources: `POST /v1/phone-numbers/{id}/voice`, `POST /v1/phone-numbers/{id}/sms`. Activity lives on its own resources: call history at `/v1/calls`, texts at `/v1/sms/messages`. > **Note:** > > Phone, SMS and voice need a payment method on file. Without one, a purchase returns `402` with `payment_required`. Numbers do not carry emergency service (911, 112). ## The lifecycle ### Check what is on offer `GET /v1/phone-numbers/countries` lists every country you can buy in, with the monthly price and a flag for each capability. It needs no API key, so you can drive a public country picker from it. ### Get a number [Buy one](https://pozy.ai/docs/telephony/buy-a-number) from live inventory, or [port in](https://pozy.ai/docs/telephony/porting) a number you already own. Regulated countries ask for identity documents first ([KYC](https://pozy.ai/docs/telephony/kyc)). ### Turn on what you need Calls are already on. [Enable SMS](https://pozy.ai/docs/telephony/sms) (and [register for 10DLC](https://pozy.ai/docs/telephony/10dlc) in the US), or [connect WhatsApp](https://pozy.ai/docs/whatsapp/phone-numbers). ### Use it [Place and receive calls](https://pozy.ai/docs/telephony/voice-calls), [send texts](https://pozy.ai/docs/telephony/sms) and read one [call history](https://pozy.ai/docs/telephony/call-recordings) across every number. ## List the countries ```bash cURL curl https://api.pozy.ai/v1/phone-numbers/countries ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/countries", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.countries) { console.log(c.code, `$${(c.monthlyCents / 100).toFixed(2)}/mo`, { sms: c.smsAvailable, whatsapp: c.whatsappAvailable, kyc: c.needsKyc, inStock: c.inStock, }); } ``` ```python Python import requests resp = requests.get("https://api.pozy.ai/v1/phone-numbers/countries", timeout=30) for c in resp.json()["countries"]: print(c["code"], c["monthlyCents"] / 100, c["smsAvailable"], c["needsKyc"]) ``` ```json { "countries": [ { "code": "US", "tier": 1, "monthlyCents": 300, "needsKyc": false, "callsAvailable": true, "smsAvailable": true, "whatsappAvailable": true, "inStock": true, "preOrderable": false, "types": [ { "numberType": "local", "monthlyCents": 300, "smsAvailable": true, "needsKyc": false, "inStock": true, "fulfilment": "instant" }, { "numberType": "toll_free", "monthlyCents": 300, "smsAvailable": false, "whatsappAvailable": false, "inStock": true, "fulfilment": "instant" } ] } ] } ``` The country fields describe its default type, which is the first entry in `types`. Countries come back cheapest first. | Field | Meaning | | --- | --- | | `monthlyCents` | Price per number per month, in US cents | | `needsKyc` | The country regulates who can hold a number. You submit identity details before the number is ordered. | | `inStock` | A stock snapshot, refreshed every few hours. The purchase checks again. | | `preOrderable` | Out of stock, but you can still order it through the KYC form. Sourcing usually takes 2 to 4 weeks. | | `fulfilment` | `instant` provisions in the request. `request` means the number is sourced to order. | | `outboundCallingAvailable` | Business-initiated WhatsApp calls. This is not about regular phone calls. | For one country in detail (area codes in stock, the address rule for KYC), call `GET /v1/phone-numbers/availability?country=GB`. See [Buy a number](https://pozy.ai/docs/telephony/buy-a-number#check-one-country). ## Number types Each number is `local`, `mobile`, `national` or `toll_free`, depending on what the country stocks. Every country has a default type chosen to work with as many capabilities as possible. Pass `numberType` to pick another. > **Warning:** > > Toll-free numbers can never connect WhatsApp. Buy them with `connectWhatsapp: false` and use them for calls and SMS. ## Pricing A number costs a flat monthly price set by its country and type. It is charged in full when the number activates and again on the 1st of each month, with no daily proration. The price is stored on the number at purchase (`monthlyCents`), so it does not change if the rate card does. Minutes and segments are billed on top. | What | Price | | --- | --- | | Outbound messages (inbox replies, broadcasts, sequences; not X DMs or SMS) | First 10,000 each month free, then $1 per 10,000 | | Managed ads (running or in review) | First 500 free, then $0.01 per active ad per month | | X post | $0.015 per post | | X post containing a link | $0.20 per post | | X reads (analytics, lookups) | $0.005 per resource | | X DMs | $0.01 per DM read, $0.015 per DM sent | | Phone numbers | From $3 per number per month, by country | | Calls | From $0.01 per minute by destination. Recording $0.004 per minute, transcription $0.03 per minute | | Branded calling | $0.10 per branded call | | SMS | From $0.008 per segment by destination | | US 10DLC registration | $9 one-time brand fee, then $20 per month per campaign ($4 for a sole proprietor) | | RCS agent | $150 setup, then $160 per month in the US or $75 elsewhere, plus $750 on US carrier approval | | Creator payouts | 4% of each transfer, minimum $0.50 | | Support assistant | At cost per answered question, capped at $100 per month | See [Pricing](https://pozy.ai/docs/billing) for how usage lands on your invoice. ## Statuses | Status | Meaning | | --- | --- | | `pending_payment` | Waiting for a payment method | | `pending_regulatory` | In regulatory review (KYC countries) | | `regulatory_declined` | The reviewer declined it. You can [fix and resubmit](https://pozy.ai/docs/telephony/kyc#fix-a-declined-number). | | `provisioning`, `verifying` | Being set up. Not billed until `active`. | | `active` | Ready to use | | `suspended` | Stopped, for example after a failed payment. Recovers to `active`. | | `releasing`, `released` | Returned to the carrier. `released` is final. | ## In this section - [Buy a number](https://pozy.ai/docs/telephony/buy-a-number): Search inventory, buy, watch sold-out countries and release. - [Port a number in](https://pozy.ai/docs/telephony/porting): Move a number you own from another carrier. - [KYC for regulated countries](https://pozy.ai/docs/telephony/kyc): Identity documents, hosted forms and declined numbers. - [Send SMS](https://pozy.ai/docs/telephony/sms): Texts, MMS, opt-outs and lookups. - [US 10DLC registration](https://pozy.ai/docs/telephony/10dlc): Register a US number so it can send. - [Voice calls](https://pozy.ai/docs/telephony/voice-calls): Inbound routing, IVR, voicemail and outbound calls. - [AI voice agents](https://pozy.ai/docs/telephony/ai-voice-agents): Let an agent answer your number. - [Branded calling](https://pozy.ai/docs/telephony/branded-calling): Your name and logo on the callee's screen. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers`](https://pozy.ai/docs/api/listPhoneNumbers) | List phone numbers | | [`GET /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/getPhoneNumber) | Get phone number | | [`DELETE /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/releasePhoneNumber) | Release phone number | | [`POST /v1/phone-numbers/purchase`](https://pozy.ai/docs/api/purchasePhoneNumber) | Purchase phone number | | [`GET /v1/phone-numbers/claims/{claimId}`](https://pozy.ai/docs/api/getPhoneNumberClaim) | Resolve a number claim | | [`GET /v1/phone-numbers/countries`](https://pozy.ai/docs/api/listPhoneNumberCountries) | List offerable number countries | | [`GET /v1/phone-numbers/available`](https://pozy.ai/docs/api/searchAvailablePhoneNumbers) | Search available numbers | | [`GET /v1/phone-numbers/availability`](https://pozy.ai/docs/api/checkPhoneNumberAvailability) | Check country availability | | [`GET /v1/phone-numbers/kyc`](https://pozy.ai/docs/api/getPhoneNumberKycForm) | Get KYC form spec | | [`POST /v1/phone-numbers/kyc`](https://pozy.ai/docs/api/submitPhoneNumberKyc) | Submit KYC | | [`GET /v1/phone-numbers/kyc/document/{documentId}`](https://pozy.ai/docs/api/viewPhoneNumberKycDocument) | View a KYC document on file | | [`POST /v1/phone-numbers/kyc/upload-document`](https://pozy.ai/docs/api/uploadPhoneNumberKycDocument) | Upload a KYC document | | [`POST /v1/phone-numbers/kyc/validate-address`](https://pozy.ai/docs/api/validatePhoneNumberKycAddress) | Pre-validate KYC address | | [`POST /v1/phone-numbers/kyc/share`](https://pozy.ai/docs/api/createPhoneNumberKycLink) | Create a hosted KYC link (not available yet) | | [`GET /v1/phone-numbers/port-in`](https://pozy.ai/docs/api/listPhoneNumberPortIns) | List port-in orders | | [`POST /v1/phone-numbers/port-in`](https://pozy.ai/docs/api/createPhoneNumberPortIn) | Port numbers in | | [`POST /v1/phone-numbers/port-in/check`](https://pozy.ai/docs/api/checkPhoneNumberPortability) | Check portability | | [`GET /v1/phone-numbers/port-in/claims/{claimId}`](https://pozy.ai/docs/api/getPhoneNumberPortClaim) | Resolve a port claim | | [`POST /v1/phone-numbers/port-in/documents`](https://pozy.ai/docs/api/uploadPhoneNumberPortInDocument) | Upload a porting document | | [`GET /v1/phone-numbers/port-in/requirements`](https://pozy.ai/docs/api/getPhoneNumberPortInRequirements) | Country porting requirements | | [`GET /v1/phone-numbers/port-in/{id}/requirements`](https://pozy.ai/docs/api/getPhoneNumberPortInOrderRequirements) | A port-in order's pending requirements | | [`DELETE /v1/phone-numbers/port-in/{id}`](https://pozy.ai/docs/api/cancelPhoneNumberPortIn) | Cancel a port-in | | [`POST /v1/phone-numbers/kyc/review-packet`](https://pozy.ai/docs/api/reviewPhoneNumberKycPacket) | Pre-review a KYC packet | | [`GET /v1/phone-numbers/{id}/remediate`](https://pozy.ai/docs/api/getPhoneNumberRemediation) | Get declined requirements | | [`POST /v1/phone-numbers/{id}/remediate`](https://pozy.ai/docs/api/remediatePhoneNumber) | Resubmit a declined number | | [`POST /v1/phone-numbers/{id}/remediate/reply`](https://pozy.ai/docs/api/replyToPhoneNumberReviewer) | Reply to the regulatory reviewer | | [`POST /v1/phone-numbers/{id}/remediate/respond`](https://pozy.ai/docs/api/respondToPhoneNumberReviewer) | Respond to the regulatory reviewer (message + corrections) | | [`GET /v1/phone-numbers/stock-watches`](https://pozy.ai/docs/api/listPhoneNumberStockWatches) | List stock watches | | [`POST /v1/phone-numbers/stock-watches`](https://pozy.ai/docs/api/createPhoneNumberStockWatch) | Watch an out-of-stock country | | [`DELETE /v1/phone-numbers/stock-watches/{id}`](https://pozy.ai/docs/api/deletePhoneNumberStockWatch) | Stop watching a country | | [`POST /v1/phone-numbers/{id}/whatsapp/request-code`](https://pozy.ai/docs/api/requestPhoneNumberWhatsAppCode) | Request the WhatsApp verification code for a number | ## Webhook events Number status events carry a `whatsapp.number.` prefix for historical reasons. They fire for every number, whether or not WhatsApp is on it. | Event | When it fires | | --- | --- | | [`whatsapp.number.activated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-activated) | WhatsApp number activated. | | [`whatsapp.number.declined`](https://pozy.ai/docs/webhooks/events#whatsapp-number-declined) | WhatsApp number declined. | | [`whatsapp.number.action_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-action-required) | WhatsApp number action required. | | [`whatsapp.number.verification_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-verification-required) | WhatsApp number verification-required. | | [`whatsapp.number.suspended`](https://pozy.ai/docs/webhooks/events#whatsapp-number-suspended) | WhatsApp number suspended. | | [`whatsapp.number.reactivated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-reactivated) | WhatsApp number reactivated. | | [`whatsapp.number.released`](https://pozy.ai/docs/webhooks/events#whatsapp-number-released) | WhatsApp number released. | | [`whatsapp.number.kyc_submitted`](https://pozy.ai/docs/webhooks/events#whatsapp-number-kyc-submitted) | WhatsApp number KYC submitted. | | [`phone_number.stock_available`](https://pozy.ai/docs/webhooks/events#phone-number-stock-available) | Phone-number stock available. | --- # Buy a number Search live inventory, buy a number in one request, watch sold-out countries and release numbers you no longer need. Buying a number takes two calls: search the inventory, then purchase. In countries that do not regulate numbers (the US among them) the number is active when the purchase returns. Regulated countries answer with a KYC step instead. ## Before you start - A payment method on file. Telephony is billed on your monthly invoice. - A profile id. Each number belongs to one profile. See [Profiles](https://pozy.ai/docs/build/profiles). ## Search available numbers `GET /v1/phone-numbers/available` queries the carrier's live inventory for a country (default `US`). Every result lists its `features`, and voice is always included. ```bash cURL curl "https://api.pozy.ai/v1/phone-numbers/available?country=US&areaCode=415&sms=true&limit=10" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/available?country=US&areaCode=415&sms=true&limit=10", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const n of data.numbers) console.log(n.phoneNumber, n.locality, n.features); ``` ```python Python import os, requests resp = requests.get( "https://api.pozy.ai/v1/phone-numbers/available", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"country": "US", "areaCode": "415", "sms": "true", "limit": 10}, timeout=30, ) for n in resp.json()["numbers"]: print(n["phoneNumber"], n.get("locality"), n["features"]) ``` ```json { "country": "US", "numberType": "local", "requireSms": true, "numbers": [ { "phoneNumber": "+14155550142", "features": ["voice", "sms", "mms"], "locality": "San Francisco" }, { "phoneNumber": "+14155550143", "features": ["voice", "sms", "mms"], "locality": "San Francisco", "bestEffort": true } ] } ``` | Query parameter | What it does | | --- | --- | | `country` | ISO 3166 alpha-2 code. Must be a country from `GET /v1/phone-numbers/countries`. | | `numberType` | `local`, `mobile`, `national` or `toll_free`. Defaults to the country's default type. | | `areaCode` | An area code (`415`) or a city name. A city covers every area code it has. | | `locality` | A city name, matched against the national numbering plan. Accents and common aliases work. | | `contains` | Digits the number must contain. | | `sms` | `true` returns SMS-capable numbers only. | | `limit` | Results to return. Default 20, maximum 100. | When too few numbers match, nearby numbers fill the list and carry `bestEffort: true`. Drop them if the exact area matters. An unknown query parameter is a `400` that lists the accepted ones. ## Purchase `POST /v1/phone-numbers/purchase` takes a `profileId` and a `country`. Leave out `phoneNumber` and Pozy assigns one. Pass a `phoneNumber` from your search to buy that exact number. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/purchase \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f0a2b3c4d5e6f70812", "country": "US", "phoneNumber": "+14155550142", "wantsSms": true, "connectWhatsapp": false, "purchaseIntentId": "order-8841-line-1" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/purchase", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1f0a2b3c4d5e6f70812", country: "US", phoneNumber: "+14155550142", wantsSms: true, connectWhatsapp: false, purchaseIntentId: "order-8841-line-1", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.phoneNumber?.id, data.phoneNumber?.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/purchase", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "6650c1f0a2b3c4d5e6f70812", "country": "US", "phoneNumber": "+14155550142", "wantsSms": True, "connectWhatsapp": False, "purchaseIntentId": "order-8841-line-1", }, timeout=60, ) print(resp.status_code, resp.json()) ``` ```json { "message": "Phone number provisioned", "phoneNumber": { "id": "6650d2e1b3c4d5e6f7081923", "phoneNumber": "+14155550142", "status": "active", "country": "US", "provisionedAt": "2026-10-30T10:12:00Z", "profileId": "6650c1f0a2b3c4d5e6f70812" } } ``` Keep `phoneNumber.id`. It is the `{id}` for every feature endpoint (`/voice`, `/sms`, `/sip-trunk`). | Body field | Default | What it does | | --- | --- | --- | | `profileId` | required | The profile that owns the number | | `country` | `US` | Country to buy in | | `numberType` | country default | `local`, `mobile`, `national` or `toll_free` | | `phoneNumber` | none | Buy this exact E.164 number. Only works where numbers activate instantly. | | `areaCode` | none | Hold an auto-assigned number to this area. The purchase fails rather than give you another area. | | `wantsSms` | `false` | Draw from SMS-capable stock and flag the number for SMS | | `connectWhatsapp` | `true` | Start the WhatsApp connection on the new number. Send `false` for a calls or SMS line. | | `wantsWhatsapp` | `false` | With `connectWhatsapp: false`, still pick a number WhatsApp will accept | | `purchaseIntentId` | none | Your own key for this purchase, up to 100 characters. A retry with the same key returns `already_purchased` instead of buying twice. | | `allowMultiple` | `false` | Confirm that a second purchase within 10 minutes is intentional | > **Purchases are not idempotent by default:** > > Always send a `purchaseIntentId`, one per number you mean to buy. A retry with the same value returns `{ "status": "already_purchased", "numberId", "phoneNumber", "profileId" }` and does not charge again. The key survives the round trip through adding a payment method. ### When the profile already has a number A profile holds one number. If the profile you pass already has one, Pozy puts the new number on the next free profile (creating one if needed) and returns it in `profileId`. Read `profileId` from the response rather than assuming. ### Regulated countries A country with `needsKyc: true` answers `202` instead of provisioning: ```json { "status": "kyc_required", "country": "GB", "numberType": "local", "kycUrl": "https://pozy.ai/kyc/..." } ``` Send your customer to `kycUrl`, or collect the details yourself with the [KYC endpoints](https://pozy.ai/docs/telephony/kyc). Passing `phoneNumber` for a regulated country or type is a `400`: the number is assigned after review. ## Check one country `GET /v1/phone-numbers/availability?country=BR&numberType=local` answers for a single country: whether stock exists, which area codes are in stock (`areaOptions`), and the address rule for KYC (`addressConstraint`). | `addressConstraint` | What the registrant needs | | --- | --- | | `geo` | An address inside the area of the number | | `country` | Any address in the country | | `none` | No address | `areaAvailability` sorts every area of the country into `inStock`, `preOrder` and `outOfStock`. Pass an `ndc` from `inStock` as `areaCode` on the purchase to hold the order to that area. ## Watch a sold-out country When a country or area has no stock, you have two options: - **Pre-order it.** If the type is `preOrderable`, submit the [KYC form](https://pozy.ai/docs/telephony/kyc#out-of-stock-pre-order) with `preOrder: true`. The number is sourced for you, usually within 2 to 4 weeks, and nothing is billed until it is active. - **Watch it.** `POST /v1/phone-numbers/stock-watches` with a `country` (and optionally `numberType` and `areaCode`) puts you on the list. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/stock-watches \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "country": "IE", "numberType": "local" }' ``` Stock is checked every few hours. When numbers come back, Pozy sends the `phone_number.stock_available` webhook, so subscribe to it to hear about it. A watch fires once and is then used up, so create it again if you miss the stock. | Rule | Value | | --- | --- | | Watches per workspace | 20 | | Watches per country and type | 1 (a repeat request returns the existing watch) | | Watch on a country that is in stock | `409`: buy instead | ## Manage numbers - `GET /v1/phone-numbers` lists your numbers. Filter by `status` or `profileId`. Released numbers are left out unless you ask for `status=released`. - `GET /v1/phone-numbers/{id}` returns one number with its features and voice settings. - `DELETE /v1/phone-numbers/{id}` releases a number. > **Releasing is permanent:** > > A released number goes back to the carrier pool and stops billing. You cannot get it back. A number attached to a SIP trunk must be [detached](https://pozy.ai/docs/telephony/sip-trunks) first, or the release returns `409 invalid_resource_state`. ## Limits | Limit | Value | | --- | --- | | Numbers per profile | 1 | | Numbers per workspace | Your profile limit (no cap where profiles are unlimited) | | Purchase velocity | A second purchase within 10 minutes needs `allowMultiple: true` | | Search results | 20 by default, 100 at most | ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `400` | | The country is not offered, or `phoneNumber` was sent for a regulated country | Pick a country from the countries list, or drop `phoneNumber` and run KYC | | `400` | | `numberType: "toll_free"` with `connectWhatsapp: true` | Send `connectWhatsapp: false` | | `402` | `payment_required` | No payment method on file | Add a card, then retry with the same `purchaseIntentId` | | `409` | `PHONE_NUMBER_UNAVAILABLE` | The exact number you picked is gone | Search again | | `409` | `AREA_CODE_UNAVAILABLE` | No deliverable stock in that `areaCode` | Pick another area, or leave it out | | `409` | `NO_WHATSAPP_ELIGIBLE_NUMBER` | Every number offered is one WhatsApp refuses | Pass an `areaCode`, or retry | | `409` | `PURCHASE_VELOCITY` | A second purchase within 10 minutes | Send `allowMultiple: true` | All errors use the standard [error shape](https://pozy.ai/docs/getting-started/errors). ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers/countries`](https://pozy.ai/docs/api/listPhoneNumberCountries) | List offerable number countries | | [`GET /v1/phone-numbers/availability`](https://pozy.ai/docs/api/checkPhoneNumberAvailability) | Check country availability | | [`GET /v1/phone-numbers/available`](https://pozy.ai/docs/api/searchAvailablePhoneNumbers) | Search available numbers | | [`POST /v1/phone-numbers/purchase`](https://pozy.ai/docs/api/purchasePhoneNumber) | Purchase phone number | | [`GET /v1/phone-numbers/claims/{claimId}`](https://pozy.ai/docs/api/getPhoneNumberClaim) | Resolve a number claim | | [`GET /v1/phone-numbers`](https://pozy.ai/docs/api/listPhoneNumbers) | List phone numbers | | [`GET /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/getPhoneNumber) | Get phone number | | [`DELETE /v1/phone-numbers/{id}`](https://pozy.ai/docs/api/releasePhoneNumber) | Release phone number | | [`GET /v1/phone-numbers/stock-watches`](https://pozy.ai/docs/api/listPhoneNumberStockWatches) | List stock watches | | [`POST /v1/phone-numbers/stock-watches`](https://pozy.ai/docs/api/createPhoneNumberStockWatch) | Watch an out-of-stock country | | [`DELETE /v1/phone-numbers/stock-watches/{id}`](https://pozy.ai/docs/api/deletePhoneNumberStockWatch) | Stop watching a country | ## Webhook events | Event | When it fires | | --- | --- | | [`whatsapp.number.activated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-activated) | WhatsApp number activated. | | [`whatsapp.number.suspended`](https://pozy.ai/docs/webhooks/events#whatsapp-number-suspended) | WhatsApp number suspended. | | [`whatsapp.number.reactivated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-reactivated) | WhatsApp number reactivated. | | [`whatsapp.number.released`](https://pozy.ai/docs/webhooks/events#whatsapp-number-released) | WhatsApp number released. | | [`phone_number.stock_available`](https://pozy.ai/docs/webhooks/events#phone-number-stock-available) | Phone-number stock available. | --- # Port a number in Move a number you already own at another carrier onto Pozy. Check portability, upload the authorization documents, submit and track the order. Porting moves a number you already have at another carrier onto Pozy, so it keeps ringing for your customers while you gain the API. The number arrives ready for calls, and ready for SMS where the order supports messaging. Porting itself is free. The number bills its normal monthly price once it is active. ## What you need - The number or numbers, in E.164. - A signed Letter of Authorization (LOA) from the account holder. - A recent invoice from the current carrier. - The account details on that carrier: account holder name, authorized person, account number, service address, and a transfer PIN for US and Canadian mobiles. ## Where porting works | Country | Notes | | --- | --- | | United States, Canada | A few business days. Some numbers qualify for same-day porting (`fastPortable: true`). | | United Kingdom | Mobiles port with a PAC code. | | Spain, Germany, France, Netherlands | Landline and national numbers. Mobiles cannot be ported yet. | | Australia, Brazil | Landline and national numbers. | International ports can take several weeks while the carrier and the local regulator review the documents. ### Check portability `POST /v1/phone-numbers/port-in/check` is read-only and creates nothing. Run it before you ask your customer for any documents. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/port-in/check \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumbers": ["+34911234567", "+14155550188"] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/port-in/check", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ phoneNumbers: ["+34911234567", "+14155550188"] }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const r of data.results) console.log(r.phoneNumber, r.portable, r.notPortableReason); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/port-in/check", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"phoneNumbers": ["+34911234567", "+14155550188"]}, timeout=30, ) for r in resp.json()["results"]: print(r["phoneNumber"], r["portable"], r["notPortableReason"]) ``` Each result has `portable`, `fastPortable`, `lineType`, `countryCode` and `phoneNumberType`. Keep `countryCode` and `phoneNumberType`: they are the `country` and `numberType` for the next step. The answer is per number because coverage differs by number type inside a country. ### Get the country requirements Skip this for US and Canadian ports. For every other country, `GET /v1/phone-numbers/port-in/requirements?country=ES&numberType=local` returns the extra fields the regulator wants, each with a `requirementId` and a `kind`. | `kind` | How to fill it | | --- | --- | | `text` | A string, such as a Spanish CIF or a UK PAC code | | `date` | An ISO date | | `file` | Upload the document in the next step and use its `documentId` as the value | | `address` | Nothing to send. The service address from `endUser` is used. | | `action` | Cannot be done through the API (`supported: false`). Contact support@pozy.ai to port this type. | ### Upload the documents `POST /v1/phone-numbers/port-in/documents` takes multipart form data: a `file` part (PDF, JPEG or PNG, up to about 4 MB, the most one API request can carry) and a `kind` part (`loa`, `invoice`, or a short slug such as `id-copy` for requirement documents). Images for requirements are converted to PDF for you. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/port-in/documents \ -H "Authorization: Bearer $POZY_API_KEY" \ -F file=@loa.pdf \ -F kind=loa ``` Each upload returns a `documentId`. Upload right before you submit: documents that are not attached to an order are deleted after 30 minutes. ### Submit the port `POST /v1/phone-numbers/port-in` with the numbers, both document ids, the current carrier account in `endUser` and, for international ports, the `requirements`. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/port-in \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumbers": ["+34911234567"], "loaDocumentId": "3f2a9c1e-7b4d-4e8a-9c6f-1d2e3f4a5b6c", "invoiceDocumentId": "8a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "endUser": { "entityName": "Acme SL", "authPersonName": "Lucia Martin", "accountNumber": "A-778812", "streetAddress": "Calle de Alcala 42", "locality": "Madrid", "postalCode": "28014", "countryCode": "ES", "taxIdentifier": "B12345674" }, "requirements": [ { "requirementTypeId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "fieldValue": "B12345674" } ], "customerReference": "acme-port-01" }' ``` ## Submit options | Field | What it does | | --- | --- | | `endUser.pinPasscode` | Transfer PIN for US and Canadian mobiles. Forwarded to the carrier, not stored. | | `endUser.administrativeArea` | State or province. Required (and checked) for US and Canada. | | `endUser.taxIdentifier`, `businessIdentifier` | For EU business ports | | `endUser.countryCode` | Service-address country. One of `US`, `CA`, `GB`, `ES`, `DE`, `FR`, `NL`, `AU`, `BR`. | | `focDatetimeRequested` | The cutover date you want. US and Canadian orders default to one week out, moved off weekends. International orders go into the carrier's next porting window on or after this date. | | `portType` | `full` (default) when the old account ports every number it holds, `partial` when it keeps some | | `customerReference` | Your own reference, up to 100 characters | Put numbers from different countries in separate requests whenever any of them is outside the US and Canada. ## Read the response The carrier may split one request into several orders (by country, number type or current carrier). `orders[]` holds the result of each. ```json { "id": "67d0e1f2a3b4c5d6e7f8a9b0", "status": "pending", "phoneNumbers": ["+34911234567"], "orders": [ { "id": "67d0e1f2a3b4c5d6e7f8a9b0", "status": "pending", "phoneNumbers": ["+34911234567"] } ] } ``` A partial failure still returns `201`. The failed orders carry an `error` and stay as drafts you can fix and resubmit. `GET /v1/phone-numbers/port-in/{id}/requirements` lists what an order is still missing at any time. ## Track the order `GET /v1/phone-numbers/port-in` lists your orders, newest first. | `status` | Meaning | | --- | --- | | `draft` | Not yet accepted by the carrier. Fix what `error` names and resubmit, or cancel. Drafts left alone for 14 days are cancelled. | | `pending` | Submitted. The current carrier is processing the transfer. | | `foc_confirmed` | The carrier confirmed the cutover date in `focDatetimeActual`. The number moves on that date. | | `ported` | Done. The number is in your list, ready for calls. | | `exception` | The current carrier flagged a problem in `declineReason`. Not final: correct the details and the order goes back to `pending`. | | `cancelled` | Stopped. It will not proceed. | Cancel any order that has not reached `ported` with `DELETE /v1/phone-numbers/port-in/{id}`. > **Tip:** > > Keep the old line active until the order reaches `ported`. Cancelling service at the current carrier before the cutover can release the number and break the port. ## After the port The ported number shows up in `GET /v1/phone-numbers` like a purchased one. From there: - [Route its calls](https://pozy.ai/docs/telephony/voice-calls#route-inbound-calls). - [Enable SMS](https://pozy.ai/docs/telephony/sms), and [register it](https://pozy.ai/docs/telephony/10dlc) if it is a US number. - [Connect WhatsApp](https://pozy.ai/docs/whatsapp/phone-numbers). ## Errors | Status | Cause | Fix | | --- | --- | --- | | `422` | A number is not portable, numbers span several non-US/CA countries, or every split order failed | Check each number and submit one country per request | | `409` | A number is already on Pozy or already in an open port | Remove it from the request | | `400` | A field failed validation, named in `param` | Fix the field | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/phone-numbers/port-in/check`](https://pozy.ai/docs/api/checkPhoneNumberPortability) | Check portability | | [`GET /v1/phone-numbers/port-in/requirements`](https://pozy.ai/docs/api/getPhoneNumberPortInRequirements) | Country porting requirements | | [`POST /v1/phone-numbers/port-in/documents`](https://pozy.ai/docs/api/uploadPhoneNumberPortInDocument) | Upload a porting document | | [`POST /v1/phone-numbers/port-in`](https://pozy.ai/docs/api/createPhoneNumberPortIn) | Port numbers in | | [`GET /v1/phone-numbers/port-in`](https://pozy.ai/docs/api/listPhoneNumberPortIns) | List port-in orders | | [`GET /v1/phone-numbers/port-in/{id}/requirements`](https://pozy.ai/docs/api/getPhoneNumberPortInOrderRequirements) | A port-in order's pending requirements | | [`GET /v1/phone-numbers/port-in/claims/{claimId}`](https://pozy.ai/docs/api/getPhoneNumberPortClaim) | Resolve a port claim | | [`DELETE /v1/phone-numbers/port-in/{id}`](https://pozy.ai/docs/api/cancelPhoneNumberPortIn) | Cancel a port-in | --- # KYC for regulated countries Submit the identity details a regulated country requires, hand the form to your customer with a branded link, and fix a declined number. Many countries regulate who can hold a phone number. Before a number in those countries is ordered, the registrant (you, or your customer) proves who they are and where they are. Pozy collects the details, files them with the carrier and tracks the review. Numbers usually activate 1 to 3 business days after you submit. You hit this flow when `GET /v1/phone-numbers/countries` shows `needsKyc: true`, or when a purchase answers `202` with `status: "kyc_required"`. ## Two ways to collect the details | | Hosted link | API | | --- | --- | --- | | Who fills it in | Your customer, on a page with your logo and colour | Your own UI | | Build effort | One call | A form per country, document upload, address checks | | Best for | Platforms whose customers hold the legal details | Teams that already collect business documents | ## Hand the form to your customer > **Not available yet:** > > Hosted KYC links are not available yet: `POST /v1/phone-numbers/kyc/share` answers `403` with `code: "feature_not_available"`. Collect the details in your own UI and [submit them through the API](#submit-through-the-api) until it opens. `POST /v1/phone-numbers/kyc/share` returns a single-use page, valid for 7 days, that walks your customer through the country's requirements. It needs no Pozy login. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/kyc/share \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f0a2b3c4d5e6f70812", "country": "GB", "language": "en", "branding": { "companyName": "Acme", "logoUrl": "https://acme.example.com/logo.png", "brandColor": "#1a73e8" }, "redirect_url": "https://acme.example.com/numbers/done" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/kyc/share", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ profileId: "6650c1f0a2b3c4d5e6f70812", country: "GB", language: "en", branding: { companyName: "Acme", logoUrl: "https://acme.example.com/logo.png", brandColor: "#1a73e8" }, redirect_url: "https://acme.example.com/numbers/done", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.url, data.expiresAt); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/kyc/share", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "profileId": "6650c1f0a2b3c4d5e6f70812", "country": "GB", "branding": {"companyName": "Acme", "logoUrl": "https://acme.example.com/logo.png"}, "redirect_url": "https://acme.example.com/numbers/done", }, timeout=30, ) print(resp.json()["url"]) ``` - `branding` puts your company name, logo and colour on the page. - `language` sets the page language: `en`, `es` or `pt-BR`. Without it, the page follows the browser and falls back to English. - `redirect_url` sends the customer back to your app afterwards, with `kyc=submitted` and `country=` added to the query string. - `areaCode` holds the order to one area. When your customer submits, the number enters review under your workspace and `whatsapp.number.kyc_submitted` fires. ## Submit through the API ### Check stock and the address rule `GET /v1/phone-numbers/availability?country=GB` says whether numbers are in stock and which address the registrant needs (`addressConstraint`: `geo`, `country` or `none`). Checking first avoids a filing that passes review but can never get a number because the address is in the wrong area. ### Get the form for the country `GET /v1/phone-numbers/kyc?country=GB` returns the fields the country requires. Add `numberType` if you want a type other than the default, and send the same value when you submit. ```json { "country": "GB", "numberType": "local", "fields": [ { "requirementId": "d7e8f9a0-b1c2-4d3e-9f4a-5b6c7d8e9f0a", "label": "Business name", "kind": "text", "example": "Acme Ltd" }, { "requirementId": "e8f9a0b1-c2d3-4e4f-8a5b-6c7d8e9f0a1b", "label": "Proof of identity", "kind": "file" }, { "requirementId": "f9a0b1c2-d3e4-4f5a-9b6c-7d8e9f0a1b2c", "label": "Business address", "kind": "address", "localTo": "GB" } ], "reusable": null, "pendingReview": false } ``` | `kind` | What to send | | --- | --- | | `text` | A string in `values`, keyed by `requirementId` | | `date` | An ISO date in `values` | | `address` | The `address` object | | `file` | An entry in `documents`, either inline base64 or a `documentId` from `POST /v1/phone-numbers/kyc/upload-document` (about 4 MB per document). Base64 makes a file about a third larger and the whole request can carry about 4 MB, so keep inline files to about 3 MB in total per submission | | `action` | An identity check the end user completes after the order, through a link. Send `endUserFirstName` and `endUserLastName` with the submit. | ### Check the packet (optional) `POST /v1/phone-numbers/kyc/validate-address` checks an address before you upload anything. `POST /v1/phone-numbers/kyc/review-packet` reviews the whole packet for likely decline reasons. ### Submit ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/kyc \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6650c1f0a2b3c4d5e6f70812", "country": "GB", "submissionId": "acme-gb-0001", "values": { "d7e8f9a0-b1c2-4d3e-9f4a-5b6c7d8e9f0a": "Acme Ltd" }, "documents": [ { "requirementId": "e8f9a0b1-c2d3-4e4f-8a5b-6c7d8e9f0a1b", "filename": "passport-both-sides.pdf", "base64": "JVBERi0xLjQK..." } ], "address": { "requirementId": "f9a0b1c2-d3e4-4f5a-9b6c-7d8e9f0a1b2c", "country_code": "GB", "street_address": "221 Baker Street", "locality": "London", "administrative_area": "London", "postal_code": "NW1 6XE" } }' ``` The response has `status: "kyc_submitted"` and the new number in `pending_regulatory`. > **ID cards need both sides:** > > Carriers reject one-sided ID cards often. Combine front and back into one file before you upload it. | Submit field | What it does | | --- | --- | | `submissionId` | Your key for this attempt. A retry with the same value returns the same number instead of ordering another. | | `quantity` | 1 to 5 numbers in the same country from one verification (a pre-order is always 1) | | `wantsSms` | Order from SMS-capable stock and turn SMS on at activation | | `reuse`, `reuseOptionId` | Reuse an approved verification you already hold for this country. The form response lists it in `reusable`. No documents needed, and the order is placed at once. | | `areaCode`, `preOrder` | Order in one area, and accept a sourced number when it is out of stock | Document bytes go straight to the carrier with the request. Pozy does not keep a copy. ## Out of stock: pre-order When `available` is `false` but `preOrderable` is `true`, submit the same form with `preOrder: true`. Pozy takes the next number that comes back in stock, or one the carrier sources for this registration. - Sourcing usually takes 2 to 4 weeks and is not guaranteed. - The number stays `pending_regulatory` until sourced. Nothing is billed until it is active. - If the carrier cannot source it, the number is declined and nothing is charged. - To cancel, release the pending number with `DELETE /v1/phone-numbers/{id}`. In `geo` countries the area comes from the address. If that area has no stock, the first submit returns `409` with `code: "area_pre_order_available"` and `details` (`areaCode`, `areaName`, `estimatedWeeks`, `billedWhenActive`). The `error` text is ready to show your customer. Once they agree to wait, resend the same body with `preOrder: true`. ## Forward the identity-check link Some countries (Australian mobiles, for example) need the end user to pass a live identity check after the order is placed. When the form had an `action` field: 1. Poll `GET /v1/phone-numbers/{id}` after submitting. The check link appears on the number in `onfidoVerificationUrl` once the order exists. 2. Or listen for `whatsapp.number.verification_required`, which carries the same link in `verificationUrl`. 3. Send the link to the person whose ID is on file. The review waits until they finish. ## Fix a declined number A declined number stays in your list with `status: "regulatory_declined"` and a `regulatoryDeclineReason`. You do not start over. 1. `GET /v1/phone-numbers/{id}/remediate` returns only the requirements the reviewer flagged. 2. `POST /v1/phone-numbers/{id}/remediate` with corrected `values`, `documents` or `address`. The number goes back to `pending_regulatory` on the same record and is not billed again. ### Answer the reviewer Sometimes the reviewer asks a question rather than flagging a field ("is this a personal or a business line?"). `POST /v1/phone-numbers/{id}/remediate/respond` sends a `message`, plus any corrected `documents` or `address` and up to 5 `attachments` (PDF, JPG, PNG or WebP, sent as base64, about 3 MB in total per request). The response `status` is `replied` for a message alone and `resubmitted` when corrections went with it. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/remediate/respond \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "This is a business line for Acme Ltd. The utility bill is in the company name." }' ``` ## Statuses and events | What happened | Number status | Event | | --- | --- | --- | | Customer submitted a hosted link | `pending_regulatory` | `whatsapp.number.kyc_submitted` | | Reviewer needs more information | stays `pending_regulatory` | `whatsapp.number.action_required` (with `reason` and per-requirement `requirements`) | | End user must pass an ID check | stays `pending_regulatory` | `whatsapp.number.verification_required` | | Approved and live | `active` | `whatsapp.number.activated` | | Declined | `regulatory_declined` | `whatsapp.number.declined` | These events carry a `whatsapp.` prefix for historical reasons and fire for every number. No event fires when you submit through the API, because you already know. > **Note:** > > `verification.approved` and `verification.failed` are not KYC events. They belong to the [one-time code API](https://pozy.ai/docs/telephony/sms#send-one-time-codes). ## Limits | Limit | Value | | --- | --- | | Review time | Usually 1 to 3 business days | | Numbers per verification | 1 to 5, same country | | Hosted link | Single use, valid 7 days | | Reviewer attachments | Up to 5 files, about 3 MB in total per request (they travel as base64) | ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `400` | | A value failed validation, often an address outside the country or an oversized file | Run `validate-address` first | | `409` | `area_pre_order_available` | The address area is out of stock but can be pre-ordered | Resend with `preOrder: true` once the customer agrees | | `409` | `area_code_unavailable` | The area has no stock and cannot be pre-ordered | Pick another area, or create a stock watch | | `409` | `country_out_of_stock` | Nothing deliverable in the country and type | Create a [stock watch](https://pozy.ai/docs/telephony/buy-a-number#watch-a-sold-out-country) | | `409` | | `reuse: true` with no approved verification on file | Submit the full form | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers/kyc`](https://pozy.ai/docs/api/getPhoneNumberKycForm) | Get KYC form spec | | [`POST /v1/phone-numbers/kyc`](https://pozy.ai/docs/api/submitPhoneNumberKyc) | Submit KYC | | [`POST /v1/phone-numbers/kyc/upload-document`](https://pozy.ai/docs/api/uploadPhoneNumberKycDocument) | Upload a KYC document | | [`GET /v1/phone-numbers/kyc/document/{documentId}`](https://pozy.ai/docs/api/viewPhoneNumberKycDocument) | View a KYC document on file | | [`POST /v1/phone-numbers/kyc/validate-address`](https://pozy.ai/docs/api/validatePhoneNumberKycAddress) | Pre-validate KYC address | | [`POST /v1/phone-numbers/kyc/review-packet`](https://pozy.ai/docs/api/reviewPhoneNumberKycPacket) | Pre-review a KYC packet | | [`POST /v1/phone-numbers/kyc/share`](https://pozy.ai/docs/api/createPhoneNumberKycLink) | Create a hosted KYC link (not available yet) | | [`GET /v1/phone-numbers/{id}/remediate`](https://pozy.ai/docs/api/getPhoneNumberRemediation) | Get declined requirements | | [`POST /v1/phone-numbers/{id}/remediate`](https://pozy.ai/docs/api/remediatePhoneNumber) | Resubmit a declined number | | [`POST /v1/phone-numbers/{id}/remediate/reply`](https://pozy.ai/docs/api/replyToPhoneNumberReviewer) | Reply to the regulatory reviewer | | [`POST /v1/phone-numbers/{id}/remediate/respond`](https://pozy.ai/docs/api/respondToPhoneNumberReviewer) | Respond to the regulatory reviewer (message + corrections) | ## Webhook events | Event | When it fires | | --- | --- | | [`whatsapp.number.activated`](https://pozy.ai/docs/webhooks/events#whatsapp-number-activated) | WhatsApp number activated. | | [`whatsapp.number.declined`](https://pozy.ai/docs/webhooks/events#whatsapp-number-declined) | WhatsApp number declined. | | [`whatsapp.number.action_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-action-required) | WhatsApp number action required. | | [`whatsapp.number.verification_required`](https://pozy.ai/docs/webhooks/events#whatsapp-number-verification-required) | WhatsApp number verification-required. | | [`whatsapp.number.kyc_submitted`](https://pozy.ai/docs/webhooks/events#whatsapp-number-kyc-submitted) | WhatsApp number KYC submitted. | --- # Send SMS Send SMS and MMS from your numbers, handle replies and delivery receipts, respect opt-outs, look up line types and send one-time codes. Send a text from any SMS-enabled number you own with one request. Replies land in the same [inbox](https://pozy.ai/docs/inbox/messages) as your other channels, and delivery receipts arrive as webhooks. Outside the US a number can send as soon as SMS is on. In the US it must pass [10DLC registration](https://pozy.ai/docs/telephony/10dlc) first. ## Turn SMS on for a number Not every number can text. Buy with `wantsSms: true` (or search with `sms=true`) to get one that can, then enable SMS on it: ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/sms \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "enabled": true, "id": "6650e3f2c4d5e6f708192a34", "phoneNumber": "+14155550142", "isActive": true, "country": "US", "smsCapable": true, "mmsCapable": true, "needsRegistration": true, "alreadyRegistered": false, "registrationStatus": null, "reusable": null } ``` | Field | Meaning | | --- | --- | | `isActive` | SMS is on. The number receives texts right away. | | `needsRegistration` | A US number that cannot send until a [10DLC or toll-free registration](https://pozy.ai/docs/telephony/10dlc) is approved | | `reusable` | You already hold an approved registration this number can join for free | | `smsCapable: false` with `enabled: false` | This number cannot text. Buy an SMS-capable one. | | `notReady: true` | Still provisioning at the carrier. Retry in a minute. | `DELETE /v1/phone-numbers/{id}/sms` turns SMS off again. ## Send a text `POST /v1/sms/messages` takes `from`, `to` and `text`. Both numbers are normalized to E.164, so formatting does not matter. ```bash cURL curl -X POST https://api.pozy.ai/v1/sms/messages \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Idempotency-Key: order-1234-shipped" \ -H "Content-Type: application/json" \ -d '{ "from": "+14155550142", "to": "+13125550188", "text": "Acme: order #1234 has shipped. Reply STOP to opt out." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/sms/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "order-1234-shipped", "Content-Type": "application/json", }, body: JSON.stringify({ from: "+14155550142", to: "+13125550188", text: "Acme: order #1234 has shipped. Reply STOP to opt out.", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.id, data.conversationId, data.status); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/sms/messages", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "order-1234-shipped", }, json={ "from": "+14155550142", "to": "+13125550188", "text": "Acme: order #1234 has shipped. Reply STOP to opt out.", }, timeout=30, ) print(resp.json()) ``` ```json { "id": "67c9d0e1f2a3b4c5d6e7f8a9", "conversationId": "66c3d2ae7b4f6c8d0e1f2a3b", "status": "sent" } ``` `sent` means the carrier accepted the message. The final outcome comes by webhook. Send an `Idempotency-Key` so a retry returns the first response instead of texting twice (see [Idempotency](https://pozy.ai/docs/getting-started/idempotency)). | Body field | What it does | | --- | --- | | `from` | One of your SMS-enabled numbers, or an [alphanumeric sender ID](https://pozy.ai/docs/telephony/sender-ids) | | `to` | Recipient number | | `text` | Message body, up to 10 segments. Optional when you send media. | | `mediaUrls` | Up to 10 public URLs. Turns the message into an MMS. | | `sendAt` | ISO 8601 time with offset, in the future, to schedule the message | ## Segments and encoding Carriers bill and deliver SMS in segments. The encoding decides how many characters fit in each one. | Encoding | Used when | One segment | Per segment of a longer message | | --- | --- | --- | --- | | GSM-7 | Plain Latin letters, digits and common symbols | 160 characters | 153 characters | | UCS-2 | Any emoji, accented letters outside GSM-7, non-Latin scripts | 70 characters | 67 characters | One emoji switches the whole message to UCS-2. A message is capped at 10 segments: 1,530 GSM-7 characters or 670 UCS-2 characters. Prices are per segment, by destination, and the carrier rates each message after it is sent, so there is no price quote endpoint for SMS. ## Send MMS Add `mediaUrls` to attach images or video. `text` becomes the caption, or leave it out to send media only. ```bash curl -X POST https://api.pozy.ai/v1/sms/messages \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+14155550142", "to": "+13125550188", "text": "Here is your receipt", "mediaUrls": ["https://acme.example.com/receipts/1234.png"] }' ``` MMS is not available from sender IDs. To send the same message to many people, use an SMS [broadcast](https://pozy.ai/docs/automations/broadcasts) instead of looping over this endpoint. ## Replies and delivery | What happens | Event | | --- | --- | | The carrier delivered your message | `message.delivered` | | Delivery failed (carrier error code included) | `message.failed` | | Someone texts your number | `message.received` with `platform: "sms"` | | The first message of a new thread | `conversation.started` | Replies thread into the same inbox conversation as the message you sent (`conversationId`). Answer them with the inbox endpoints or another `POST /v1/sms/messages`. See [Messages](https://pozy.ai/docs/inbox/messages) and [Webhooks](https://pozy.ai/docs/webhooks). ## Opt-outs When a recipient replies STOP, the carrier opts them out. Further sends to them return `409`: nothing is dropped silently. Only the recipient can opt back in, by replying START. Keep your own suppression list in sync with `GET /v1/sms/opt-outs`: ```bash curl "https://api.pozy.ai/v1/sms/opt-outs?limit=1000" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "optOuts": [ { "phoneNumber": "+13125550199", "optedOutAt": "2026-10-28T09:30:00Z", "keyword": "STOP", "from": "+14155550142" } ], "count": 1 } ``` The list is most recent first. `limit` defaults to 500 and goes up to 5,000. There is no cursor, so raise `limit` instead of paging. Add `format=csv` for a file. > **Tip:** > > Put "Reply STOP to opt out" in the first message you send to a new contact. US carriers expect it, and it is one of the things 10DLC reviewers check. ## Look up a number `GET /v1/sms/lookup?number=%2B13125550188` returns the carrier, the line type (`mobile`, `landline`, `voip`, `toll-free` or `unknown`) and `smsReachable`. Each lookup is billed by the carrier-data source, so run it when you clean an opt-in list, not before every send. ## Send one-time codes > **Not available yet:** > > The Verify API is not available yet. Its endpoints answer `403` with `code: "feature_not_available"`. Until it opens, send your own codes with [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) and check them on your side. The Verify API sends a one-time code by SMS or WhatsApp and checks it for you. You never store the code. ```bash cURL curl -X POST https://api.pozy.ai/v1/verify/verifications \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "sms", "to": "+13125550188", "brandName": "Acme", "codeLength": 6, "ttlMinutes": 10 }' ``` ```ts Node const vRes = await fetch("https://api.pozy.ai/v1/verify/verifications", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "sms", to: "+13125550188", brandName: "Acme" }), }); const v = await vRes.json(); if (!vRes.ok) throw new Error(v.error); // later, with the code the user typed const resultRes = await fetch(`https://api.pozy.ai/v1/verify/verifications/${v.id}/check`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ code: "482913" }), }); const result = await resultRes.json(); if (!resultRes.ok) throw new Error(result.error); console.log(result.valid, result.status); ``` ```python Python v = requests.post( "https://api.pozy.ai/v1/verify/verifications", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"channel": "sms", "to": "+13125550188", "brandName": "Acme"}, timeout=30, ).json() check = requests.post( f"https://api.pozy.ai/v1/verify/verifications/{v['id']}/check", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"code": "482913"}, timeout=30, ).json() print(check["valid"], check["status"]) ``` | Field | Default | Notes | | --- | --- | --- | | `channel` | required | `sms` or `whatsapp` | | `codeLength` | 6 | Up to 8 digits | | `ttlMinutes` | 10 | Up to 15 minutes | | `brandName` | none | Up to 30 characters, shown in the message | | `from` | auto | A number of yours to send from | A verification moves through `pending`, then `approved`, `expired`, `max_attempts_reached`, `canceled` or `delivery_failed`. `verification.approved` fires when the right code is checked, and `verification.failed` (with `reason: "max_attempts_reached"`) when attempts run out. ## Limits | Limit | Value | | --- | --- | | Text length | 10 segments (1,530 GSM-7 or 670 UCS-2 characters) | | MMS media | 10 public URLs per message | | Destinations | Only countries on the SMS rate card. Others are blocked at send time, not billed. | | Opt-out list | 500 by default, 5,000 at most, no cursor | ## Errors | Status | Cause | Fix | | --- | --- | --- | | `403` | A US number whose registration is not approved yet | Wait for `approved` on the [registration](https://pozy.ai/docs/telephony/10dlc) | | `404` | No SMS-enabled number you own matches `from` | Send from one of your numbers | | `409` | The recipient replied STOP, or the same `Idempotency-Key` is still in flight | Drop the recipient, or wait for the first request | | `422` | The `Idempotency-Key` was reused with a different body | Use a new key for a new message | | `402` | No payment method on file | Add a payment method | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/enableSmsOnNumber) | Enable SMS on a number | | [`DELETE /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/disableSmsOnNumber) | Disable SMS on a number | | [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) | Send an SMS/MMS | | [`GET /v1/sms/opt-outs`](https://pozy.ai/docs/api/listSmsOptOuts) | List SMS opt-outs | | [`GET /v1/sms/lookup`](https://pozy.ai/docs/api/lookupSmsNumber) | Look up carrier + line type | | [`POST /v1/verify/verifications`](https://pozy.ai/docs/api/createVerification) | Send a verification code (not available yet) | | [`GET /v1/verify/verifications/{verificationId}`](https://pozy.ai/docs/api/getVerification) | Get a verification (not available yet) | | [`POST /v1/verify/verifications/{verificationId}/check`](https://pozy.ai/docs/api/checkVerification) | Check a verification code (not available yet) | ## Webhook events | Event | When it fires | | --- | --- | | [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. | | [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. | | [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. | | [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. | | [`verification.approved`](https://pozy.ai/docs/webhooks/events#verification-approved) | Verification approved. | | [`verification.failed`](https://pozy.ai/docs/webhooks/events#verification-failed) | Verification failed. | --- # US 10DLC registration Not available yet. Register a US number with the carriers so it can send SMS, as a company, a sole proprietor or a toll-free number, then reuse the approval on more numbers. > **Not available yet:** > > US carrier registration is not available yet. [`POST /v1/sms/registrations`](https://pozy.ai/docs/api/startSmsRegistration) and [adding a number to a registration](https://pozy.ai/docs/api/reuseSmsRegistrationForNumber) answer `403` with `code: "feature_not_available"`, so US numbers can receive texts but cannot send them for now. Numbers in other countries send as soon as SMS is on. The rest of this page describes registration as it will work. US carriers block SMS from numbers that are not registered. A local 10-digit number (10DLC) needs a registered **brand** (who is sending) and **campaign** (what they send and how people opted in). A toll-free number goes through toll-free verification instead. Pozy files both through one endpoint, `POST /v1/sms/registrations`, reviews the filing before it reaches the carriers, and tracks it to approval. A US number receives texts from the moment SMS is on. It can send only once its registration is `approved`. Numbers in other countries skip all of this. ## Choose the registration type | `registrationType` | For | Needs | | --- | --- | --- | | `standard_10dlc` | Companies, non-profits, government | `brand` (with EIN and business phone) and `campaign` | | `sole_prop_10dlc` | Individuals without an EIN | `brand` with a `mobilePhone` for a one-time code, and `campaign` | | `toll_free` | Toll-free numbers | `tollFree` details. No brand, no campaign. | ## What it costs Registration adds a one-time brand fee and a monthly campaign fee to your invoice. Adding more numbers to an approved registration is free. | What | Price | | --- | --- | | Outbound messages (inbox replies, broadcasts, sequences; not X DMs or SMS) | First 10,000 each month free, then $1 per 10,000 | | Managed ads (running or in review) | First 500 free, then $0.01 per active ad per month | | X post | $0.015 per post | | X post containing a link | $0.20 per post | | X reads (analytics, lookups) | $0.005 per resource | | X DMs | $0.01 per DM read, $0.015 per DM sent | | Phone numbers | From $3 per number per month, by country | | Calls | From $0.01 per minute by destination. Recording $0.004 per minute, transcription $0.03 per minute | | Branded calling | $0.10 per branded call | | SMS | From $0.008 per segment by destination | | US 10DLC registration | $9 one-time brand fee, then $20 per month per campaign ($4 for a sole proprietor) | | RCS agent | $150 setup, then $160 per month in the US or $75 elsewhere, plus $750 on US carrier approval | | Creator payouts | 4% of each transfer, minimum $0.50 | | Support assistant | At cost per answered question, capped at $100 per month | ## Before you start 1. Buy or port an SMS-capable US number. 2. Enable SMS on it with `POST /v1/phone-numbers/{id}/sms`. The response tells you whether the number `needsRegistration` and whether a `reusable` approval already covers it. See [Send SMS](https://pozy.ai/docs/telephony/sms#turn-sms-on-for-a-number). ## Register a company (standard 10DLC) ```bash cURL curl -X POST https://api.pozy.ai/v1/sms/registrations \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "registrationType": "standard_10dlc", "phoneNumbers": ["+14155550142"], "brand": { "entityType": "PRIVATE_PROFIT", "displayName": "Acme", "companyName": "Acme Inc.", "ein": "12-3456789", "phone": "+14155550199", "email": "ops@acme.example.com", "website": "https://acme.example.com", "vertical": "RETAIL", "street": "500 Howard St", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" }, "campaign": { "usecase": "CUSTOMER_CARE", "description": "Order updates and replies to support questions for Acme customers.", "messageFlow": "Customers tick an unchecked SMS consent box at checkout on https://acme.example.com/checkout.", "sample1": "Acme: order #1234 has shipped and arrives Thursday. Reply STOP to opt out.", "sample2": "Acme: a support agent answered your question. Reply HELP for help.", "optinKeywords": "START", "optoutKeywords": "STOP", "helpKeywords": "HELP" } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/sms/registrations", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ registrationType: "standard_10dlc", phoneNumbers: ["+14155550142"], brand: { entityType: "PRIVATE_PROFIT", displayName: "Acme", companyName: "Acme Inc.", ein: "12-3456789", phone: "+14155550199", website: "https://acme.example.com", vertical: "RETAIL", street: "500 Howard St", city: "San Francisco", state: "CA", postalCode: "94105", country: "US", }, campaign: { usecase: "CUSTOMER_CARE", description: "Order updates and replies to support questions for Acme customers.", messageFlow: "Customers tick an unchecked SMS consent box at checkout on https://acme.example.com/checkout.", sample1: "Acme: order #1234 has shipped and arrives Thursday. Reply STOP to opt out.", sample2: "Acme: a support agent answered your question. Reply HELP for help.", optinKeywords: "START", optoutKeywords: "STOP", helpKeywords: "HELP", }, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.registrationId, data.status); // "pending" ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/sms/registrations", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "registrationType": "standard_10dlc", "phoneNumbers": ["+14155550142"], "brand": { "entityType": "PRIVATE_PROFIT", "displayName": "Acme", "companyName": "Acme Inc.", "ein": "12-3456789", "phone": "+14155550199", "website": "https://acme.example.com", "vertical": "RETAIL", "street": "500 Howard St", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US", }, "campaign": { "usecase": "CUSTOMER_CARE", "description": "Order updates and replies to support questions for Acme customers.", "messageFlow": "Customers tick an unchecked SMS consent box at checkout on https://acme.example.com/checkout.", "sample1": "Acme: order #1234 has shipped and arrives Thursday. Reply STOP to opt out.", "sample2": "Acme: a support agent answered your question. Reply HELP for help.", "optinKeywords": "START", "optoutKeywords": "STOP", "helpKeywords": "HELP", }, }, timeout=60, ) print(resp.json()) ``` > **Always send phoneNumbers:** > > If you leave out `phoneNumbers`, the registration enrolls every active SMS-enabled US local number in the workspace that no other registration covers. That is rarely what one campaign should cover. ## Write a filing that passes Carriers reject thin filings. These fields decide most outcomes: | Field | What reviewers look for | | --- | --- | | `brand.website` | A live site for the business. Sole proprietors can use a social profile URL. | | `brand` address | A full street address: `street`, `city`, `state`, `postalCode` | | `brand.phone` | A business phone, required for company, non-profit and government brands | | `campaign.messageFlow` | How people opt in, with a link to the page where it happens. Up to 2,048 characters. | | `campaign.sample1`, `sample2` | Two different, realistic messages (20 characters or more) that match the use case and include opt-out wording | | Keywords | `optinKeywords`, `optoutKeywords` and `helpKeywords` are all required | `optinMessage`, `optoutMessage` and `helpMessage` are optional: Pozy writes compliant auto-replies when you leave them out. `subUsecases`, `embeddedLink`, `embeddedPhone`, `ageGated`, `privacyPolicyLink` and `termsAndConditionsLink` describe the campaign in more detail. > **Prove the opt-in:** > > Upload a screenshot of your opt-in form with `POST /v1/sms/opt-in-proof` (PNG, JPG, WebP, GIF or PDF, up to 4 MB) and put the returned URL inside `messageFlow`. Reviewers reject campaigns whose consent they cannot see. ### Check before you submit `POST /v1/sms/registrations/preflight` takes the same body, runs the same checks plus a compliance review, and creates nothing. It returns a `verdict` (`pass`, `warn`, `fail` or `unreviewed`) and a list of `advisories`, each with a `severity` of `block` or `warn`. ## Register a sole proprietor Use `registrationType: "sole_prop_10dlc"` and `brand.entityType: "SOLE_PROPRIETOR"`, with a `mobilePhone` on the brand. The response has `awaitingOtp: true`, and a code is texted to that mobile. ```bash curl -X POST https://api.pozy.ai/v1/sms/registrations/67a7b8c9d0e1f2a3b4c5d6e7/verify-otp \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otpPin": "482913" }' ``` If the code expired or never arrived, `POST /v1/sms/registrations/{id}/resend-otp` sends a new one (at most once a minute). ## Verify a toll-free number Toll-free numbers are verified separately, so the body takes a `tollFree` object instead of `brand` and `campaign`. Every field in it is required except `businessAddr2`. ```json { "registrationType": "toll_free", "phoneNumbers": ["+18445550100"], "tollFree": { "businessName": "Acme Inc.", "corporateWebsite": "https://acme.example.com", "phoneNumbers": ["+18445550100"], "useCase": "Customer care", "useCaseSummary": "Order updates and support replies for Acme customers.", "productionMessageContent": "Acme: order #1234 has shipped. Reply STOP to opt out.", "optInWorkflow": "Customers tick the SMS updates box at checkout on https://acme.example.com/checkout.", "optInWorkflowImageUrls": ["https://pozy.ai/media/opt-in-proof/acme-checkout.png"], "messageVolume": "10,000", "additionalInformation": "Transactional messages only.", "businessAddr1": "500 Howard St", "businessCity": "San Francisco", "businessState": "CA", "businessZip": "94105", "businessContactFirstName": "Dana", "businessContactLastName": "Reyes", "businessContactEmail": "dana@acme.example.com", "businessContactPhone": "+14155550199", "businessRegistrationNumber": "12-3456789", "businessRegistrationType": "EIN", "businessRegistrationCountry": "US" } } ``` `optInWorkflowImageUrls` needs at least one screenshot of the opt-in form (upload it with `POST /v1/sms/opt-in-proof`). `messageVolume` is a monthly tier: `10`, `100`, `1,000`, `10,000`, `100,000`, `250,000`, `500,000`, `750,000`, `1,000,000`, `5,000,000` or `10,000,000+`. Toll-free verification has no code step and cannot be appealed. ## Reuse an approval on more numbers Once one registration is approved, add more numbers to it instead of registering again. There is no new brand fee and no extra campaign. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081924/sms/reuse-registration \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```json { "registrationId": "67a7b8c9d0e1f2a3b4c5d6e7", "status": "approved" } ``` Enable SMS on the number first. Its response shows the `reusable` registration. ## Let your customer fill it in > **Not available yet:** > > Hosted registration forms are not available yet: `POST /v1/sms/registrations/share` answers `403` with `code: "feature_not_available"`. When your customer holds the legal business details, `POST /v1/sms/registrations/share` with a `numberId` returns a single-use link (valid 7 days). Your customer completes the form without a Pozy login, and the registration is created under your workspace. ## Track the registration `GET /v1/sms/registrations/{id}` returns the current state. Branch on `status`, not on the raw carrier fields (`brandStatus`, `campaignStatus`). | `status` | Meaning | What to do | | --- | --- | --- | | `requested` | New or resubmitted, in Pozy's review | Wait | | `changes_requested` | The review needs answers. `reviewRequest.points` lists them. | Answer with `POST /v1/sms/registrations/{id}/respond` | | `pending` | With the carriers. On a sole proprietor, `awaitingOtp: true` means it waits for the code. | Wait, or submit the code | | `approved` | Live. Every attached number can send. | Send, or add more numbers | | `rejected` | Declined. `declineReason` says why. | Fix and appeal | | `deactivated` | Ended. Hidden from the list unless `includeDeactivated=true`. | | > **Note:** > > Status can go backwards. An approved registration drops back to `pending` if the carriers suspend the campaign. Treat `status` as the current state, not a milestone you passed once. ### Answer a change request `POST /v1/sms/registrations/{id}/respond` takes `answers`, one per point (`pointId` plus `text`, `url` or `files`), and an optional `note`. The registration goes back to `requested`. ### Appeal a rejected campaign `POST /v1/sms/registrations/{id}/appeal` takes an `appealReason` (up to 2,000 characters) that answers `declineReason` directly, plus any corrected `messageFlow`, `sample1` or `sample2`. The content on file comes back as `campaignContent` on a rejected registration. A rejection at brand level cannot be appealed: correct the brand and file again. ### Stop a registration `DELETE /v1/sms/registrations/{id}` deactivates a brand or campaign registration. Its numbers can no longer send. ## Webhook events | Event | When | | --- | --- | | `sms.registration.status_updated` | Every status change. On `rejected`, `reason` has the carriers' words. | | `sms.registration.action_required` | Only when you must act. `reason` is `changes_requested`, `otp_required` or `carrier_info_required` (a toll-free information request, which expires after 7 days). | Both carry `registration.id` and `registration.profileId`, not an account id. | Event | When it fires | | --- | --- | | [`sms.registration.action_required`](https://pozy.ai/docs/webhooks/events#sms-registration-action-required) | SMS registration action required. | | [`sms.registration.status_updated`](https://pozy.ai/docs/webhooks/events#sms-registration-status-updated) | SMS registration status updated. | ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `400` | `missing_required_field` | A required field is missing. `param` names it, for example `campaign.sample2`. | Add it. Run preflight to catch every gap at once. | | `422` | | The carrier registry rejected a field | Correct the field named in `param` and resubmit | | `403` | | You tried to send before `approved` | Wait for approval | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/sms/registrations`](https://pozy.ai/docs/api/startSmsRegistration) | Start a carrier registration (not available yet) | | [`POST /v1/sms/registrations/preflight`](https://pozy.ai/docs/api/preflightSmsRegistration) | Pre-check a carrier registration | | [`GET /v1/sms/registrations`](https://pozy.ai/docs/api/listSmsRegistrations) | List carrier registrations | | [`GET /v1/sms/registrations/{id}`](https://pozy.ai/docs/api/getSmsRegistration) | Get a carrier registration | | [`POST /v1/sms/registrations/{id}/verify-otp`](https://pozy.ai/docs/api/verifySmsRegistrationOtp) | Submit the sole-prop OTP | | [`POST /v1/sms/registrations/{id}/resend-otp`](https://pozy.ai/docs/api/resendSmsRegistrationOtp) | Re-send the sole-prop OTP | | [`POST /v1/sms/registrations/{id}/respond`](https://pozy.ai/docs/api/respondToSmsRegistrationReview) | Reply to a change request | | [`POST /v1/sms/registrations/{id}/appeal`](https://pozy.ai/docs/api/appealSmsRegistration) | Appeal a rejected campaign | | [`POST /v1/sms/opt-in-proof`](https://pozy.ai/docs/api/uploadSmsOptInProofFile) | Upload opt-in form proof | | [`POST /v1/sms/registrations/{id}/opt-in-proof`](https://pozy.ai/docs/api/uploadSmsOptInProof) | Upload opt-in form proof for an appeal | | [`POST /v1/sms/registrations/share`](https://pozy.ai/docs/api/shareSmsRegistration) | Create a registration share link (not available yet) | | [`POST /v1/phone-numbers/{id}/sms/reuse-registration`](https://pozy.ai/docs/api/reuseSmsRegistrationForNumber) | Add number to SMS registration (not available yet) | | [`DELETE /v1/sms/registrations/{id}`](https://pozy.ai/docs/api/deactivateSmsRegistration) | Deactivate a brand/campaign registration | --- # Alphanumeric sender IDs Send one-way international SMS from your brand name instead of a phone number, with a daily limit and per-country behaviour. A sender ID lets your texts arrive from a name such as `ACME` instead of a number, the way banks and airlines send alerts. There is no number to buy, no monthly fee and no US registration. The trade-off: recipients cannot reply, and sender IDs do not work for the US, Canada or Puerto Rico. ## When to use one | Use a sender ID for | Use a phone number for | | --- | --- | | One-way alerts, codes and notices abroad | Two-way conversations | | Recipients outside North America | Recipients in the US, Canada or Puerto Rico | | Text only | MMS | ## Create the name `POST /v1/sms/sender-ids` with a `senderId`. It is usable at once. ```bash cURL curl -X POST https://api.pozy.ai/v1/sms/sender-ids \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "ACME" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/sms/sender-ids", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ senderId: "ACME" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.id, data.isActive); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/sms/sender-ids", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"senderId": "ACME"}, timeout=30, ) print(resp.json()) ``` Rules for the name: - 3 to 11 characters: letters, digits and spaces, with at least one letter. - It must be your own brand. Names that imitate well-known brands or institutions (banks, payment providers, delivery companies, government bodies) are rejected with `422`. - Names are not exclusive. Other workspaces can register the same name. Billing and delivery receipts always follow the workspace that sent the message. `DELETE /v1/sms/sender-ids/{id}` deactivates a name. Creating the same name again brings it back. ## Send from it Pass the name as `from` on the normal send endpoint: ```bash curl -X POST https://api.pozy.ai/v1/sms/messages \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "ACME", "to": "+34612345678", "text": "Your Acme verification code is 482913" }' ``` Each segment bills at the destination's normal SMS rate. Delivery is reported with `message.delivered` and `message.failed`, like any SMS. See [Send SMS](https://pozy.ai/docs/telephony/sms). ## How countries display it | Behaviour | Examples | | --- | --- | | Shows the name as sent | Most of Europe and many other markets | | Replaces the name with a number to guarantee delivery | Some countries | | Requires the name to be registered with local carriers first | India and the United Arab Emirates, among others. Contact support@pozy.ai for these. | ## Daily limit Texts sent from sender IDs count against a daily limit that resets at midnight UTC. When it is reached, sends from a sender ID answer `429` until the reset. Texts from your phone numbers are not affected. `GET /v1/sms/sender-ids` returns your names. Write to support@pozy.ai to raise your sender ID limit, with what the texts are for, who receives them and roughly how many you send a day. `POST /v1/sms/sender-ids/limit-request` is not available yet and answers `403` with `code: "feature_not_available"`. ## Limits | Limit | Value | | --- | --- | | Name length | 3 to 11 characters | | Active names per workspace | 1,000 | | Replies | Not possible | | MMS | Not available | | Destinations | Not the US, Canada or Puerto Rico | ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `403` | `alpha_destination_not_supported` | `to` is in the US, Canada or Puerto Rico | Send from a [phone number](https://pozy.ai/docs/telephony/buy-a-number) | | `403` | `mms_not_supported` | `mediaUrls` on a sender ID send | Remove the media | | `429` | `alpha_daily_limit_reached` | Today's sender ID limit is reached | Wait for midnight UTC, or write to support@pozy.ai to raise your sender ID limit | | `403` | `sender_id_limit_reached` | The workspace has 1,000 active names | Contact support | | `422` | | The name imitates another brand or institution | Use your own brand name | | `402` | | No payment method on file | Add a payment method | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/sms/sender-ids`](https://pozy.ai/docs/api/listSmsSenderIds) | List alphanumeric sender IDs | | [`POST /v1/sms/sender-ids`](https://pozy.ai/docs/api/createSmsSenderId) | Create an alphanumeric sender ID | | [`DELETE /v1/sms/sender-ids/{id}`](https://pozy.ai/docs/api/deleteSmsSenderId) | Delete an alphanumeric sender ID | | [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) | Send an SMS/MMS | --- # Voice calls Route inbound calls to a phone, a SIP address or an AI agent, add voicemail, business hours and an IVR menu, and place, transfer and end outbound calls. Every Pozy number takes and makes regular phone calls as soon as it is active. Inbound calls go wherever you point them: a mobile, a SIP endpoint, or an [AI voice agent](https://pozy.ai/docs/telephony/ai-voice-agents). Outbound calls dial from your number with one request. Every call, in either direction, lands in one [call history](https://pozy.ai/docs/telephony/call-recordings). Calls are billed per connected minute by destination. Unanswered, busy and failed calls are not charged. > **Note:** > > These are phone network calls. Voice calls inside a WhatsApp chat are a separate feature: see [WhatsApp calling](https://pozy.ai/docs/whatsapp/calling). Both appear in the same history. ## Route inbound calls All inbound settings live on one idempotent endpoint, `POST /v1/phone-numbers/{id}/voice`. It writes only the fields you send, so you can call it again later to change one setting. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "forwardTo": "tel:+13125550188", "forwardCallerId": "caller", "voicemailEnabled": true, "voicemailGreeting": "You have reached Acme. Leave a message and we will call you back." }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ forwardTo: "tel:+13125550188", forwardCallerId: "caller", voicemailEnabled: true, voicemailGreeting: "You have reached Acme. Leave a message and we will call you back.", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.enabled, data.pstnForwardTo); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "forwardTo": "tel:+13125550188", "forwardCallerId": "caller", "voicemailEnabled": True, "voicemailGreeting": "You have reached Acme. Leave a message and we will call you back.", }, timeout=30, ) print(resp.json()) ``` The response is the full effective configuration (`pstnForwardTo`, voicemail, hours, IVR, blocklist, recording settings). ### Destinations | `forwardTo` | Sends the call to | | --- | --- | | `tel:+13125550188` | A phone number | | `sip:agent@pbx.example.com` | A SIP endpoint or a hosted voice agent | | `wss://agent.example.com/media` | Your own media server, for a custom AI agent | Leave `forwardTo` out to keep the current destination. An empty string clears it, which leaves the number able to call out but not to ring anywhere on inbound. ### Inbound features | Feature | Fields | Behaviour | | --- | --- | --- | | Voicemail | `voicemailEnabled`, `voicemailGreeting` | On by default. Catches unanswered calls (about 25 seconds of ringing), failed bridges and after-hours calls. Records up to 2 minutes. | | Business hours | `businessHoursEnabled`, `businessHoursTimezone`, `businessHours` | Rings only inside the windows. Outside them the call goes to voicemail. | | IVR menu | `ivrEnabled`, `ivrPrompt`, `ivrOptions` | Plays a menu and routes by keypress. Replaces the plain forward. | | Blocklist | `blockedCallers` | Rejects listed numbers with a busy signal. Each write replaces the whole list. | | Caller ID | `forwardCallerId` | Show your number (`business`, default) or the original caller (`caller`) on the forwarded leg | | Recording | `recordingEnabled` | Off by default. A consent notice plays first. | | Transcription | `transcriptionEnabled`, `transcriptionLanguage` | Off by default. Language `auto`, `en` or `es`. | Business hours and the IVR menu take structured values: ```json { "businessHoursEnabled": true, "businessHoursTimezone": "Europe/Madrid", "businessHours": [ { "day": 1, "open": "09:00", "close": "18:00" }, { "day": 2, "open": "09:00", "close": "18:00" }, { "day": 6, "open": "10:00", "close": "14:00" } ], "ivrEnabled": true, "ivrPrompt": "Press 1 for sales, 2 for support, 3 to talk to our assistant.", "ivrOptions": [ { "digit": "1", "forwardTo": "tel:+13125550188", "label": "Sales" }, { "digit": "2", "forwardTo": "sip:support@pbx.example.com", "label": "Support" }, { "digit": "3", "forwardTo": "wss://agent.example.com/media", "label": "Assistant" } ] } ``` - A window has `day` (0 is Sunday through 6), `open` and `close` as `HH:MM` in the timezone. A day with no window is closed. A window whose `close` is before its `open` runs past midnight. Up to 21 windows. - A menu option has a `digit` (`0` to `9`, `*` or `#`), its own `forwardTo` and an optional `label`. Up to 12 options. No input or an unknown digit ends the call. ### The order an inbound call follows 1. **Blocklist.** A blocked caller gets a busy signal before the call is answered: no charge, no voicemail, no webhook. 2. **`call.received` fires.** 3. **Business hours.** Outside the windows, the call goes straight to voicemail (or hangs up if voicemail is off). 4. **IVR menu**, if on. The digit picks a destination. 5. **Ring the destination.** If nobody answers or the bridge fails, voicemail takes the call. 6. **`call.ended`** (or `call.failed` on a hard error). Voicemail sends the account an email. The recording, and the transcript when transcription is on, attach to the call record. There is no separate voicemail webhook: `call.ended` carries `isVoicemail: true`. ### Turn calling off `DELETE /v1/phone-numbers/{id}/voice` disables calls on the number. Pozy keeps the settings, so enabling again restores them. ## Place an outbound call `POST /v1/voice/calls` with `to`. When the callee answers, Pozy bridges them to the number's forward destination, or to a per-call `forwardTo`. An optional `greeting` is spoken to the callee first. ```bash cURL curl -X POST https://api.pozy.ai/v1/voice/calls \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Idempotency-Key: callback-ticket-5521" \ -H "Content-Type: application/json" \ -d '{ "fromNumber": "+14155550142", "to": "+13125550188", "forwardTo": "tel:+14155550100", "greeting": "Hi, this is Acme returning your call. Connecting you now." }' ``` ```ts Node const callRes = await fetch("https://api.pozy.ai/v1/voice/calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "callback-ticket-5521", "Content-Type": "application/json", }, body: JSON.stringify({ fromNumber: "+14155550142", to: "+13125550188", forwardTo: "tel:+14155550100", greeting: "Hi, this is Acme returning your call. Connecting you now.", }), }); const call = await callRes.json(); if (!callRes.ok) throw new Error(call.error); console.log(call.callId, call.status); // "dialing" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/voice/calls", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "callback-ticket-5521", }, json={ "fromNumber": "+14155550142", "to": "+13125550188", "forwardTo": "tel:+14155550100", "greeting": "Hi, this is Acme returning your call. Connecting you now.", }, timeout=30, ) print(resp.json()["callId"]) ``` A `200` means the call is dialing. Everything after that is asynchronous: follow it with `GET /v1/voice/calls/{id}`, or with `call.ended` and `call.failed`. Outbound phone calls do not send `call.received`. | Field | What it does | | --- | --- | | `to` | Number to call (required) | | `fromNumber` | Which of your numbers to call from. Optional when you own exactly one voice-enabled number. | | `forwardTo` | Who the callee is bridged to: `tel:`, `sip:` or `wss://` (an AI agent) | | `greeting` | Spoken to the callee before the bridge, up to 1,000 characters | | `amd` | Answering-machine detection. The bridge waits until Pozy knows whether a person answered. | | `voicemailDropMessage` | With `amd`, speak this to an answering machine and hang up | | `recordOverride`, `transcribeOverride`, `transcriptionLanguage` | Override the number's defaults for this call | | `ringTimeoutSeconds` | How long to ring. Default 30, up to 120. | Send an `Idempotency-Key` so a retry does not dial (and bill) a second call. ### Estimate the cost first `GET /v1/voice/calls/estimate?to=%2B13125550188&minutes=5&recording=true` returns `perMinuteUsd` and a `breakdown` whose `billableCostUSD` is what you will be charged, using the same formula as the invoice. Use it to show prices to your users or to block expensive routes. Estimates cover outbound destinations only. ### Transfer and hang up - `POST /v1/voice/calls/{id}/transfer` with `to` (a `+E164` number or a `sip:` URI) hands a live call to someone else. The new leg always shows your number. `wss://` is not a valid transfer target. - `POST /v1/voice/calls/{id}/end` hangs up. It is safe to repeat: ending a finished call returns `200` with its current status. It also ends WhatsApp calls. Final duration and cost are written when the hangup lands, so a call record can show its previous status for a moment. ## Webhook events | Call | Events, in order | | --- | --- | | Inbound | `call.received` at ring time, then `call.ended` or `call.failed` | | Outbound phone call (API or browser) | `call.ended` or `call.failed` only | `call.ended` carries `durationSeconds`, `endReason` (`hangup`, `no_answer`, `rejected` or `error`), `isVoicemail`, `callErrors`, a cost breakdown in `billing`, and a signed `recordingUrl` when recording was on. Unanswered, busy and rejected calls end with `call.ended`, so branch on `endReason`. `call.failed` is for hard errors before or during the bridge. > **Missed-call text-back:** > > On an inbound `call.ended` with `endReason: "no_answer"`, text the caller back: send an SMS from `call.to` (your number) to `call.from`. The caller gets an answer even when nobody picked up. `call.received` also carries a `conversationId` if you would rather reply in the existing inbox thread. | Event | When it fires | | --- | --- | | [`call.received`](https://pozy.ai/docs/webhooks/events#call-received) | Call received. | | [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. | | [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. | | [`call.permission_request`](https://pozy.ai/docs/webhooks/events#call-permission-request) | Call permission request reply. | ## Limits | Limit | Value | | --- | --- | | Outbound calls | 60 per rolling hour across all numbers on the key. Past it, `429`. | | Business hours | 21 windows | | IVR options | 12 | | Ringing before voicemail | About 25 seconds | | Voicemail message | 2 minutes | | Emergency calls | Not supported | ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `422` | | No voice-enabled number matches `fromNumber`, or the number has no destination and the call sent no `forwardTo` | Set `forwardTo` on the number or the call | | `409` | | Transfer on a call that is not connected yet or already ended | Transfer only while live | | `409` | `invalid_resource_state` | The number is attached to a [SIP trunk](https://pozy.ai/docs/telephony/sip-trunks), which owns its calls | Detach it first | | `422` | | The number is a WhatsApp number on your own carrier, so Pozy cannot route its calls | Use a number bought or ported into Pozy | | `429` | | Outbound cap reached | Wait for the rolling hour | | `502` | | The carrier refused to place the call. It is marked `failed`. | Retry | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/enableVoiceOnNumber) | Enable phone calling on a number | | [`DELETE /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/disableVoiceOnNumber) | Disable phone calling on a number | | [`POST /v1/voice/calls`](https://pozy.ai/docs/api/createVoiceCall) | Place an outbound phone call | | [`GET /v1/voice/calls/estimate`](https://pozy.ai/docs/api/getVoiceCallEstimate) | Estimate call cost | | [`GET /v1/voice/calls`](https://pozy.ai/docs/api/listVoiceCalls) | List phone calls | | [`GET /v1/voice/calls/{id}`](https://pozy.ai/docs/api/getVoiceCall) | Get a phone call | | [`POST /v1/voice/calls/{id}/transfer`](https://pozy.ai/docs/api/transferVoiceCall) | Blind-transfer a live call | | [`POST /v1/voice/calls/{id}/end`](https://pozy.ai/docs/api/endVoiceCall) | Hang up a live call | --- # AI voice agents Have an AI voice agent answer your number or take outbound calls, either a hosted agent over SIP or your own speech pipeline over a WebSocket. An AI voice agent is a forward destination like any other. Point a number at it and the agent answers every inbound call. Pass it as `forwardTo` on one outbound call and the agent talks to whoever picks up. Pozy owns the phone side: the number, the carrier leg, routing, recording and the call history. The agent owns the conversation. ## Pick a shape | | Hosted agent over SIP | Your own agent over WebSocket | | --- | --- | --- | | Destination | `sip:@` | `wss://your-server.example.com/media` | | Where the prompt, voice and tools live | In the agent platform you use | In your own code: speech-to-text, an LLM, text-to-speech | | What you build | Nothing on the phone side | A WebSocket server that reads and writes call audio | | Agent can place its own calls | Use a [SIP trunk](https://pozy.ai/docs/telephony/sip-trunks) | Start calls with `POST /v1/voice/calls` | Either way there is no second phone leg, so you pay only for the call itself. ## Point a number at the agent ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "forwardTo": "wss://agent.acme.example.com/media", "transcriptionEnabled": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ forwardTo: "wss://agent.acme.example.com/media", transcriptionEnabled: true }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.pstnForwardTo); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"forwardTo": "wss://agent.acme.example.com/media", "transcriptionEnabled": True}, timeout=30, ) print(resp.json()["pstnForwardTo"]) ``` A hosted agent works the same way with a `sip:` address in `forwardTo`. Many hosted platforms identify the number from the SIP user part: keep the number in E.164 with the leading `+`, written the same way on both sides. A `+` missing on one side is the usual reason a call connects and drops at once. The agent sits inside the normal inbound flow, so the blocklist, business hours, an IVR option that sends one digit to the agent, and voicemail all still apply. See [Voice calls](https://pozy.ai/docs/telephony/voice-calls#route-inbound-calls). ## Hand one outbound call to an agent Pass the agent as the per-call destination. The number's inbound setting stays as it is. ```bash curl -X POST https://api.pozy.ai/v1/voice/calls \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Idempotency-Key: reminder-appt-7781" \ -H "Content-Type: application/json" \ -d '{ "fromNumber": "+14155550142", "to": "+13125550188", "forwardTo": "wss://agent.acme.example.com/media", "amd": true, "voicemailDropMessage": "Hi, this is Acme confirming your appointment tomorrow at 10. Call us back if you need to change it." }' ``` With `amd: true`, the agent is only bridged in when a person answers. An answering machine gets the `voicemailDropMessage` instead. ## Build your own agent over WebSocket When the destination is `wss://`, the call's audio streams to your server as soon as the call is answered, and audio you send back is played to the caller. Pozy does not re-encode the audio. | Setting | Value | | --- | --- | | Audio you receive | The caller's side of the call | | Codec, both directions | PCMU (G.711 u-law), 8 kHz | | Framing | JSON text frames with an `event` field (`start`, `media`, `stop`, `error`). Audio travels base64-encoded in `media.payload`. | | Speaking back | Write `media` frames with base64 PCMU on the same socket | | Ending | Closing the socket ends the call | These settings are fixed today, so build the pipeline around 8 kHz PCMU. A typical loop: 1. Accept the WebSocket and wait for `start`. 2. Feed `media` payloads to speech-to-text. 3. Send each finished user turn to your LLM with the system prompt and tools. 4. Turn the reply into speech, encode it as PCMU at 8 kHz, and write it back as `media` frames. 5. On `stop`, or when the conversation is done, close the socket. ### Give the agent tools Your agent's backend holds the Pozy API key, so Pozy endpoints make good agent tools. Match the call to its Pozy id with the `call.received` webhook, which fires as an inbound call starts and carries `call.id`, `from`, `to` and `conversationId`. For outbound calls, keep the `callId` from the create response. | Tool | Endpoint | | --- | --- | | Hand the caller to a person | `POST /v1/voice/calls/{id}/transfer` with a `tel:` or `sip:` destination. Do this before you close the socket. | | Hang up | `POST /v1/voice/calls/{id}/end` | | Text a link or a summary | `POST /v1/sms/messages` from the number that was called | | Reply in the inbox thread | The inbox send endpoints with the `conversationId` from `call.received` | | Look up or save the caller | The [contacts](https://pozy.ai/docs/inbox/contacts) endpoints | > **Tip:** > > The same media bridge serves [WhatsApp calls](https://pozy.ai/docs/whatsapp/calling). An agent you build once can answer both channels. ## Capture the conversation Turn on `recordingEnabled` and `transcriptionEnabled` on the number when you want Pozy to keep its own copy of the call. The recording and transcript attach to the call record after hangup. See [Call recordings](https://pozy.ai/docs/telephony/call-recordings). ## When it does not connect | Symptom | Likely cause | What to check | | --- | --- | --- | | Caller hears voicemail right after answering | The WebSocket handshake failed | The `wss://` URL, TLS certificate, and that your server accepts the connection. Read `callErrors` on the call. | | Call connects then drops at once (SIP) | The number format differs between Pozy and the agent platform | Use the same E.164 with `+` on both sides | | `409 invalid_resource_state` on the voice settings | The number is attached to a SIP trunk | Detach it, or configure the agent on the trunk side | If the stream cannot be set up, an already-answered caller falls to voicemail when it is on. Otherwise the call ends. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/enableVoiceOnNumber) | Enable phone calling on a number | | [`POST /v1/voice/calls`](https://pozy.ai/docs/api/createVoiceCall) | Place an outbound phone call | | [`POST /v1/voice/calls/{id}/transfer`](https://pozy.ai/docs/api/transferVoiceCall) | Blind-transfer a live call | | [`POST /v1/voice/calls/{id}/end`](https://pozy.ai/docs/api/endVoiceCall) | Hang up a live call | | [`GET /v1/calls/{id}`](https://pozy.ai/docs/api/getCall) | Get a call (any channel) | ## Webhook events | Event | When it fires | | --- | --- | | [`call.received`](https://pozy.ai/docs/webhooks/events#call-received) | Call received. | | [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. | | [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. | --- # SIP trunks Connect your own PBX or voice AI platform to your Pozy numbers over SIP, so it receives inbound calls directly and places outbound calls with your number. A SIP trunk takes Pozy out of the audio path for a number. Inbound calls go straight to your SIP host, and your system can place outbound calls through the trunk presenting the number as caller ID. Use it when a PBX, a contact-centre platform or a voice AI platform should own the calls, while the number, its KYC, SMS and WhatsApp messaging stay on Pozy. ## Forwarding or a trunk Both connect a number to a SIP destination. They differ in who controls the call. | | Forwarding (`forwardTo: "sip:..."`) | SIP trunk | | --- | --- | --- | | Inbound calls reach your system | Yes, bridged by Pozy | Yes, delivered directly | | Your system can place outbound calls | No | Yes, with your number as caller ID | | Pozy voicemail, IVR, business hours, recording, browser calling | Available | Paused while the number is on the trunk | | Call webhooks and history in Pozy | Yes | No: your platform's own call events are the source | | WhatsApp calling on the number | Works | Not available | | SMS and WhatsApp messaging | Unaffected | Unaffected | If you only need inbound calls answered, [forwarding](https://pozy.ai/docs/telephony/voice-calls#route-inbound-calls) is simpler. Choose a trunk when your system must also dial out. ### Create the trunk `POST /v1/phone-numbers/sip-trunks` with a `label` and your `sipHost`. Set `sipPort` and `transport` (`tls`, `tcp` or `udp`) if your host needs them. ```bash cURL curl -X POST https://api.pozy.ai/v1/phone-numbers/sip-trunks \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Support PBX", "sipHost": "pbx.acme.example.com", "sipPort": 5061, "transport": "tls" }' ``` ```ts Node const trunkRes = await fetch("https://api.pozy.ai/v1/phone-numbers/sip-trunks", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ label: "Support PBX", sipHost: "pbx.acme.example.com", sipPort: 5061, transport: "tls" }), }); const trunk = await trunkRes.json(); if (!trunkRes.ok) throw new Error(trunk.error); // Store trunk.digestPassword now. It is only returned here. console.log(trunk.id, trunk.termination.uri, trunk.termination.username); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/phone-numbers/sip-trunks", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"label": "Support PBX", "sipHost": "pbx.acme.example.com", "sipPort": 5061, "transport": "tls"}, timeout=30, ) trunk = resp.json() print(trunk["id"], trunk["termination"], trunk["digestPassword"]) ``` ```json { "id": "66f6a7b8c9d0e1f2a3b4c5d6", "label": "Support PBX", "sipHost": "pbx.acme.example.com", "sipPort": 5061, "transport": "tls", "termination": { "uri": "sip.example-termination.net", "username": "pz66f6a7b8c9d0e1f2" }, "numbersAttached": 0, "digestPassword": "rK9v...Qw2" } ``` > **Warning:** > > `digestPassword` is shown only in this response. Store it in your secrets manager. If you lose it, rotate with `POST /v1/phone-numbers/sip-trunks/{id}/rotate-credentials`, which invalidates the old password at once. ### Configure your system In your PBX or voice platform, point outbound calls at `termination.uri` and authenticate with SIP digest using `termination.username` and the `digestPassword`. No IP allowlisting is needed on either side. Register numbers in E.164 with the leading `+`: the trunk uses `+E.164` in both directions. ### Free the number A number on a trunk cannot also run Pozy calling. Turn calling off with `DELETE /v1/phone-numbers/{id}/voice`, and turn off WhatsApp calling if it is on. ### Attach the number `POST /v1/phone-numbers/{id}/sip-trunk` with the `trunkId`. From that moment, inbound calls go to your `sipHost`. ```bash curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/sip-trunk \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "trunkId": "66f6a7b8c9d0e1f2a3b4c5d6" }' ``` Detach any time with `DELETE /v1/phone-numbers/{id}/sip-trunk`. Pozy routing and voice features come back immediately. ## One trunk or several Attach as many numbers as you like to one trunk. Several trunks can point at the same host, each with its own credentials and spend cap. That keeps environments or clients apart: production and staging, or one trunk per agency client. ## Security and media | Topic | Behaviour | | --- | --- | | Signalling | TLS by default (TLS 1.2 or later at the edge). TCP and UDP are available when your host needs them. | | Media | SRTP is paired with TLS. G.711 (PCMU and PCMA) is native. Any transcoding happens in the carrier core, not on your side. | | Authentication | SIP digest. Credentials are generated by Pozy, shown once, rotatable, and unique per trunk. | | Caller ID | Outbound calls can only present numbers hosted in your workspace (or verified onto it). Other `From` numbers are rejected, so the trunk cannot spoof numbers you do not own. | | Transfers | Inbound SIP REFER is honoured, so your system can transfer callers mid-call. | | Emergency calls | Not supported | ## Caps Each trunk carries protective limits. Contact support@pozy.ai to raise them. | Cap | Default | | --- | --- | | Concurrent calls | 10 | | Daily spend | A ceiling per trunk | | Destination rate | A per-minute rate cap, which blocks premium-rate routes | | Destinations | Countries where Pozy sells numbers | Calls through a trunk bill per minute at the route's normal rate. [Branded calling](https://pozy.ai/docs/telephony/branded-calling) still applies to calls from an attached number. ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `409` | `invalid_resource_state` | On attach: the number still has calling or WhatsApp calling on, is mid WhatsApp verification, or is not active | Turn those off, wait for `active`, then attach | | `409` | `invalid_resource_state` | On voice settings or release: the number is attached to a trunk | Detach it first | | `403` | `feature_not_available` | Telephony billing is not set up for the workspace | Add a payment method, or contact support@pozy.ai | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/phone-numbers/sip-trunks`](https://pozy.ai/docs/api/listSipTrunks) | List SIP trunks | | [`POST /v1/phone-numbers/sip-trunks`](https://pozy.ai/docs/api/createSipTrunk) | Create a SIP trunk | | [`GET /v1/phone-numbers/sip-trunks/{id}`](https://pozy.ai/docs/api/getSipTrunk) | Get a SIP trunk | | [`DELETE /v1/phone-numbers/sip-trunks/{id}`](https://pozy.ai/docs/api/deleteSipTrunk) | Delete a SIP trunk | | [`POST /v1/phone-numbers/sip-trunks/{id}/rotate-credentials`](https://pozy.ai/docs/api/rotateSipTrunkCredentials) | Rotate a SIP trunk's password | | [`POST /v1/phone-numbers/{id}/sip-trunk`](https://pozy.ai/docs/api/attachNumberToSipTrunk) | Attach a number to a SIP trunk | | [`DELETE /v1/phone-numbers/{id}/sip-trunk`](https://pozy.ai/docs/api/detachNumberFromSipTrunk) | Detach a number from its SIP trunk | | [`DELETE /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/disableVoiceOnNumber) | Disable phone calling on a number | --- # Browser calling Let your users place phone calls from a web page. Mint a short-lived session on your server, register it in the browser, then dial. Browser calling turns a web page into a phone. Your users call from your app with a headset, using one of your numbers as caller ID, and the call shows up in history like any other outbound call. The API key never leaves your server. ## How it works The browser registers before anything rings, so a call is never bridged to a page that is not ready. 1. Your server mints a WebRTC session with `POST /v1/voice/calls/web`. 2. The browser registers that session with a WebRTC client. 3. Your server dials with `POST /v1/voice/calls/web/dial`. When the callee answers, the call is bridged to the registered browser. ### Mint a session on your server ```bash cURL curl -X POST https://api.pozy.ai/v1/voice/calls/web \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const sessionRes = await fetch("https://api.pozy.ai/v1/voice/calls/web", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const session = await sessionRes.json(); if (!sessionRes.ok) throw new Error(session.error); // Send session.token and session.credentialId to the browser. Never send your API key. ``` ```python Python import os, requests session = requests.post( "https://api.pozy.ai/v1/voice/calls/web", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, timeout=30, ).json() ``` ```json { "success": true, "token": "eyJhbGciOi...", "credentialId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "expiresAt": "2026-10-30T11:00:00Z", "sdk": "" } ``` The token lives about an hour, and it must outlive the whole call, not only the handshake. Mint a fresh session per calling session rather than caching one. ### Register in the browser `sdk` names the WebRTC client package that accepts this token. Install it in your front end, log in with `token`, and wait for the client's ready event. Registration carries no audio on its own: the dialled leg arrives as an incoming call in the `ringing` state, and your page answers it. ```ts // `WebRtcClient` stands for the client class exported by the package named in `sdk`. const client = new WebRtcClient({ login_token: token }); client.on("ready", () => { // Registered. Tell your server it can dial now. fetch("/api/dial", { method: "POST", body: JSON.stringify({ to: "+13125550188" }) }); }); client.on("call", (call) => { if (call.state === "ringing") call.answer(); }); client.connect(); ``` Check the package's own reference for the exact event names. The flow is always the same: connect, wait for ready, answer the ringing call, hang up when done. ### Dial from your server `POST /v1/voice/calls/web/dial` with `to` and the `credentialId` from the first step. ```bash curl -X POST https://api.pozy.ai/v1/voice/calls/web/dial \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "credentialId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "to": "+13125550188", "fromNumber": "+14155550142" }' ``` The response has the `callId` and `status: "dialing"`. ## Dial options | Field | What it does | | --- | --- | | `to` | Number to call (required) | | `credentialId` | The session from the first step (required) | | `fromNumber` | Caller ID. Optional when you own exactly one voice-enabled number. | | `recordOverride` | Turn recording on or off for this call | | `ringTimeoutSeconds` | Default 30, up to 120 | Browser calling is paused on a number while it is attached to a [SIP trunk](https://pozy.ai/docs/telephony/sip-trunks). Otherwise, browser calls follow the normal outbound rules: they appear as outbound in [history](https://pozy.ai/docs/telephony/call-recordings), use the number's recording and transcription settings, carry [branded calling](https://pozy.ai/docs/telephony/branded-calling) when the number has it, end with `call.ended` or `call.failed`, and count toward the outbound cap of 60 calls per rolling hour. To hang up from your server, call `POST /v1/voice/calls/{id}/end`. To hang up from the page, end the call in the WebRTC client. ## Errors | Status | Cause | Fix | | --- | --- | --- | | `422` | The `credentialId` is unknown or expired, or no voice-enabled number matches `fromNumber` | Mint a new session, register it, then dial | | `429` | Outbound cap of 60 calls per rolling hour reached | Wait | ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/voice/calls/web`](https://pozy.ai/docs/api/createVoiceWebSession) | Mint a browser softphone session | | [`POST /v1/voice/calls/web/dial`](https://pozy.ai/docs/api/dialVoiceWebCall) | Dial from the browser softphone | | [`POST /v1/voice/calls/{id}/end`](https://pozy.ai/docs/api/endVoiceCall) | Hang up a live call | | [`GET /v1/voice/calls/{id}`](https://pozy.ai/docs/api/getVoiceCall) | Get a phone call | ## Webhook events | Event | When it fires | | --- | --- | | [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. | | [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. | --- # Call history and recordings Read every call across your numbers and channels from one feed, open a call with its transcript, and fetch a fresh recording link. `GET /v1/calls` is one feed of every call on your numbers: phone and WhatsApp, inbound and outbound, newest first. Each call record carries its duration, end reason, cost, and the recording and transcript when those were on. ## Read from one place, act on the channel | Surface | Use it to | | --- | --- | | `/v1/calls` | Read. List, fetch and get recordings for every call on both channels, with `contactId` and `contactName` when the other party is a known contact. | | `/v1/voice/calls` | Act on phone calls: place, transfer, end, browser dial, estimate. Its list and get return phone calls only. | | `/v1/whatsapp/calls` | Act on WhatsApp calls. See [WhatsApp calling](https://pozy.ai/docs/whatsapp/calling). | ## List calls ```bash cURL curl "https://api.pozy.ai/v1/calls?direction=inbound&status=ended&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node let before: string | undefined; do { const query = new URLSearchParams({ limit: "100" }); if (before) query.set("before", before); const res = await fetch(`https://api.pozy.ai/v1/calls?${query}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const c of data.calls) console.log(c.channel, c.direction, c.status, c.durationSeconds, c.contactName); before = data.nextCursor ?? undefined; } while (before); ``` ```python Python import os, requests params = {"limit": 100} while True: page = requests.get( "https://api.pozy.ai/v1/calls", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params=params, timeout=30, ).json() for c in page["calls"]: print(c["channel"], c["direction"], c["status"], c.get("durationSeconds")) if not page["nextCursor"]: break params["before"] = page["nextCursor"] ``` | Filter | Values | | --- | --- | | `channel` | `pstn` (phone network) or `whatsapp` | | `direction` | `inbound` or `outbound` | | `status` | `ringing`, `answered`, `ended` or `failed` | | `number` | Calls involving one of your numbers | | `search` | Digits from either side of the call | | `limit` | Default 50, up to 200 | | `before` | The `nextCursor` from the previous page. It is `null` on the last page. | List rows leave out the full `transcript`. `lastTranscriptSnippet` is a preview. ## Open one call `GET /v1/calls/{id}` returns the full record for a call on either channel. ```json { "call": { "_id": "66e5f6a7b8c9d0e1f2a3b4c5", "channel": "pstn", "direction": "inbound", "from": "+13125550188", "to": "+14155550142", "status": "ended", "endReason": "hangup", "durationSeconds": 184, "isVoicemail": false, "recordingEnabled": true, "transcriptionEnabled": true, "transcript": [ { "text": "Hi, I am calling about order 1234.", "confidence": 0.96, "at": "2026-10-30T09:00:06Z" } ], "billing": { "billableCostUSD": 0.135, "currency": "USD" }, "conversationId": "66c3d2ae7b4f6c8d0e1f2a3b" } } ``` | Field | Meaning | | --- | --- | | `endReason` | `hangup`, `no_answer`, `rejected` or `error` | | `hangupCause`, `sipHangupCause` | The raw carrier cause and SIP code behind `endReason`, for example to tell a busy line from a refused destination | | `isVoicemail` | The call was taken by voicemail | | `answeredMachine` | With answering-machine detection on, whether a machine picked up | | `callErrors` | Problems recorded during the call (failed bridge, recording error). The `message` text is for humans and may change, so do not parse it. | | `billing.billableCostUSD` | What Pozy bills for the call | ## Get the recording `recordingUrl` on a call record is signed and expires about 10 minutes after it is issued. For a fresh link, call `GET /v1/calls/{id}/recording`. By default it redirects (`302`) to a playable MP3. Add `as=json` to get `{ "url": "..." }` instead. ```bash curl "https://api.pozy.ai/v1/calls/66e5f6a7b8c9d0e1f2a3b4c5/recording?as=json" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Fetch the link when someone presses play rather than storing it. If you need to keep recordings, download the file and store it yourself. ## Turn recording and transcription on Both are off by default. Set them on the number for every call, or per call when you dial out. | Where | Fields | | --- | --- | | Every call on a number | `recordingEnabled`, `transcriptionEnabled`, `transcriptionLanguage` on `POST /v1/phone-numbers/{id}/voice` | | One outbound call | `recordOverride`, `transcribeOverride`, `transcriptionLanguage` on `POST /v1/voice/calls` | | One browser call | `recordOverride` on `POST /v1/voice/calls/web/dial` | Recording never starts silently: on bridged calls a consent notice plays first. A recording exists only if recording was on when the call started. Voicemail messages are recorded whenever voicemail takes a call. > **Note:** > > Recording and transcription laws differ by country and state. Some places require every party to agree before a call is recorded. The consent notice helps, but you are responsible for using recordings lawfully. ## What it costs Recording and transcription are billed per minute on top of the call. See the usage table on [Phone numbers](https://pozy.ai/docs/telephony#pricing) or [Pricing](https://pozy.ai/docs/billing). ## Get calls pushed instead of polling `call.ended` carries the finished call (duration, end reason, cost, and a signed `recordingUrl` with `recordingExpiresAt` when recording was on). The transcript is not in the event: fetch the call record when you need it. | Event | When it fires | | --- | --- | | [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. | | [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. | ## Errors | Status | Cause | Fix | | --- | --- | --- | | `404` | The call does not exist for this key, or it has no recording | Check `recordingEnabled` on the call. Recording must be on before the call starts. | | `502` | The recording lookup failed | Retry | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/calls`](https://pozy.ai/docs/api/listCalls) | List all calls (unified history) | | [`GET /v1/calls/{id}`](https://pozy.ai/docs/api/getCall) | Get a call (any channel) | | [`GET /v1/calls/{id}/recording`](https://pozy.ai/docs/api/getCallRecording) | Get a call recording | --- # Branded calling Not available yet. Show your business name, logo and reason for calling on the callee's phone when you call from US numbers. Register the business, pass vetting, attach numbers. > **Not available yet:** > > Branded calling is not available yet: [creating a caller identity](https://pozy.ai/docs/api/createBrandedCallingIdentity) answers `403` with `code: "feature_not_available"`. Registering your business still works. The rest of this page describes branded calling as it will work. With branded calling, the person you call sees your business name, logo and reason for calling instead of a plain number or a "Spam Likely" warning. It applies to calls from US numbers to US mobile phones. Attach a number to a verified caller identity and all its calls carry the brand; you dial exactly as before. ## Where it shows The carriers verify that you are eligible. They do not promise the display on every phone. | Shows | Does not show | | --- | --- | | Calls from US numbers to US mobiles on T-Mobile and Verizon | Landlines and international destinations | | Samsung, Motorola and TCL phones on Android 14 or later | Most unlocked, bring-your-own and prepaid-brand phones (may show less or nothing) | | iPhone XS or newer on iOS 18.5 or later | Other carriers | Outbound calls placed through the API, the [browser](https://pozy.ai/docs/telephony/browser-calling) or a [SIP trunk](https://pozy.ai/docs/telephony/sip-trunks) are all branded, because the identity belongs to the number. ## What it costs | Item | Price | | --- | --- | | Caller identity | $100 a month for as long as it is verified. You pay the first month only once the carrier verifies it, and nothing while it is in review or vetting, or if it is turned down. | | Branded call | $0.10 for each outbound call from an attached number to a US number, added to the per-minute rate. Carriers do not say which calls showed the brand, so all of those calls are counted. | | Registering the business, attaching numbers | Free | Deleting the identity ends the monthly fee. The month in progress is not prorated. ## Before you start - A voice-enabled US number. - A payment method on file. Without one, creating an identity returns `403 usage_billing_required`. - A business registered in the US or Canada, with a federal tax id. Vetting involves people: the carrier phones three references you name. Expect days, not minutes. ### Register the business `POST /v1/branded-calling/enterprises` with the legal entity. Nothing is filed with the carrier yet. The tax id is stored encrypted, and only its last four digits are ever returned (`feinLast4`). One business can have any number of identities. ```bash cURL curl -X POST https://api.pozy.ai/v1/branded-calling/enterprises \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "legalName": "Acme Plumbing LLC", "doingBusinessAs": "Acme Plumbing", "organizationType": "commercial", "organizationLegalType": "llc", "countryCode": "US", "jurisdictionOfIncorporation": "Illinois", "website": "https://acmeplumbing.example", "fein": "12-3456789", "industry": "home services", "numberOfEmployees": "11-50", "organizationContact": { "firstName": "Sam", "lastName": "Rivera", "email": "sam@acmeplumbing.example", "jobTitle": "Owner", "phoneNumber": "+13125550100" }, "billingContact": { "firstName": "Pat", "lastName": "Lee", "email": "billing@acmeplumbing.example", "phoneNumber": "+13125550101" }, "physicalAddress": { "streetAddress": "100 N State St", "city": "Chicago", "administrativeArea": "IL", "postalCode": "60602", "country": "US" }, "billingAddress": { "streetAddress": "100 N State St", "city": "Chicago", "administrativeArea": "IL", "postalCode": "60602", "country": "US" } }' ``` ```ts Node const businessRes = await fetch("https://api.pozy.ai/v1/branded-calling/enterprises", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ legalName: "Acme Plumbing LLC", doingBusinessAs: "Acme Plumbing", organizationType: "commercial", organizationLegalType: "llc", countryCode: "US", jurisdictionOfIncorporation: "Illinois", website: "https://acmeplumbing.example", fein: "12-3456789", industry: "home services", numberOfEmployees: "11-50", organizationContact: { firstName: "Sam", lastName: "Rivera", email: "sam@acmeplumbing.example", jobTitle: "Owner", phoneNumber: "+13125550100" }, billingContact: { firstName: "Pat", lastName: "Lee", email: "billing@acmeplumbing.example", phoneNumber: "+13125550101" }, physicalAddress: { streetAddress: "100 N State St", city: "Chicago", administrativeArea: "IL", postalCode: "60602", country: "US" }, billingAddress: { streetAddress: "100 N State St", city: "Chicago", administrativeArea: "IL", postalCode: "60602", country: "US" }, }), }); const business = await businessRes.json(); if (!businessRes.ok) throw new Error(business.error); console.log(business.id, business.feinLast4); ``` ```python Python import os, requests address = {"streetAddress": "100 N State St", "city": "Chicago", "administrativeArea": "IL", "postalCode": "60602", "country": "US"} resp = requests.post( "https://api.pozy.ai/v1/branded-calling/enterprises", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "legalName": "Acme Plumbing LLC", "doingBusinessAs": "Acme Plumbing", "organizationType": "commercial", "organizationLegalType": "llc", "countryCode": "US", "jurisdictionOfIncorporation": "Illinois", "website": "https://acmeplumbing.example", "fein": "12-3456789", "industry": "home services", "numberOfEmployees": "11-50", "organizationContact": {"firstName": "Sam", "lastName": "Rivera", "email": "sam@acmeplumbing.example", "phoneNumber": "+13125550100"}, "billingContact": {"firstName": "Pat", "lastName": "Lee", "email": "billing@acmeplumbing.example", "phoneNumber": "+13125550101"}, "physicalAddress": address, "billingAddress": address, }, timeout=30, ) print(resp.json()["id"]) ``` If you already registered the same business for [10DLC](https://pozy.ai/docs/telephony/10dlc), copy the legal name, tax id, address and contacts from that registration instead of asking again. ### Create the caller identity `POST /v1/branded-calling/identities` describes what the callee sees and what the carrier vets. Send an `Idempotency-Key` so a retry does not create a second identity. ```json { "enterpriseId": "66f1a2b3c4d5e6f7a8b9c0d1", "displayName": "Acme Plumbing", "callReasons": ["Appointment Reminder", "Customer Service"], "logoUrl": "https://acmeplumbing.example/logo.png", "authorizer": { "name": "Sam Rivera", "email": "sam@acmeplumbing.example" }, "references": { "business": [ { "fullName": "Dana Reyes", "jobTitle": "Operations Manager", "organization": "Reyes Supply", "relationshipToRegistrant": "Supplier", "phoneNumber": "+14155550123", "email": "dana@reyessupply.example", "timezone": "America/Chicago" }, { "fullName": "Lee Park", "jobTitle": "Owner", "organization": "Park Realty", "relationshipToRegistrant": "Client", "phoneNumber": "+14155550124", "email": "lee@parkrealty.example", "timezone": "America/New_York" } ], "financial": { "fullName": "Morgan Cole", "jobTitle": "CPA", "organization": "Cole Accounting", "phoneNumber": "+14155550125", "email": "morgan@coleaccounting.example", "timezone": "America/Chicago" } } } ``` | Field | Rules | | --- | --- | | `displayName` | Between 1 and 35 characters, without emoji: the name people know the business by. Using some other brand's name is refused as impersonation. | | `callReasons` | 1 to 10 reasons, up to 64 characters each. Reasons from `GET /v1/branded-calling/call-reasons` pass automatically. Other wording is vetted by hand and takes longer. | | `logoUrl` | HTTPS PNG, JPEG, WebP or SVG. Use the mark on the business's own website. Pozy converts and hosts it in the format carriers need. | | `authorizer` | A real person at the business. The carrier emails them a 6-digit code after review. Avoid free-mail addresses. | | `references` | Two business references (senior contacts at a supplier, partner or client) and one financial reference (the company's accountant or bank contact). The vetting team phones each one during their local business hours, so give direct numbers, monitored inboxes and the right IANA timezone. | ### Preflight the body `POST /v1/branded-calling/identities/preflight` applies the review's checks without creating anything, and returns `ok` with a list of `findings`. A `block` finding means review would fail; a `warn` finding means vetting will take longer. Codes include `display_name_differs_from_legal_name`, `call_reason_not_in_catalogue`, `duplicate_reference_phone`, `reference_inside_business`, `invalid_timezone`, `free_mail_authorizer` and `logo_unreachable`. ### Pass review and confirm the code The identity starts in `requested` while Pozy reviews it. When it passes, it is filed with the carrier and moves to `pending_email_verification`. The authorizer receives a 6-digit code, and `branded_calling.identity.action_required` fires with `reason: "email_code"`. Confirm it: ```bash curl -X POST https://api.pozy.ai/v1/branded-calling/identities/66f1a2b3c4d5e6f7a8b9c0d2/verify-email/confirm \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "482915" }' ``` Now the identity is `in_review`, and the carrier's vetting team will call your references, so warn them first. If the email code ran out before it was used, `POST /v1/branded-calling/identities/{id}/verify-email` sends another. ### Attach numbers Once the identity is `verified`, attach up to 15 active US numbers per call. Attaching files a letter of authorization with the carrier, so you include a signature image and the signer's name. Send an `Idempotency-Key` here too. ```bash curl -X POST https://api.pozy.ai/v1/branded-calling/identities/66f1a2b3c4d5e6f7a8b9c0d2/numbers \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Idempotency-Key: brand-attach-2026-10-30" \ -H "Content-Type: application/json" \ -d '{ "phoneNumberIds": ["6650d2e1b3c4d5e6f7081923"], "signature": { "imageBase64": "data:image/png;base64,iVBORw0KGgo...", "signerName": "Sam Rivera" } }' ``` Numbers are attached together or not at all: if any one is not eligible, the request fails with `422`. Every number begins as `submitted` and displays the identity when it becomes `verified`. ## Identity statuses | Status | Meaning | What to do | | --- | --- | --- | | `requested` | In Pozy review | Wait. Review usually finishes the same day. | | `changes_requested` | Review needs something from you | Answer the points (below) | | `rejected` | Declined by review or by the carrier | Fix and resubmit | | `pending_email_verification` | Filed. Waiting for the authorizer's code. | Confirm the code | | `in_review` | With the carrier's vetting team | Wait. Warn the references. | | `verified` | Live | Attach numbers | | `suspended` | Someone filed a trademark or impersonation claim | Wait for the claim to close. Until then branding is off and the identity cannot be deleted. | | `expired` | The yearly renewal failed | Edit and resubmit | | `permanently_rejected` | Final | Create a new identity | Number statuses are `submitted`, `in_review`, `verified`, `unsuccessful`, `suspended`, `expired` and `permanently_rejected`. > **Important:** > > Once the carrier sets a number to `permanently_rejected`, your workspace can never brand it again, so check the display name and references carefully before filing. ## Answer a change request When an identity is `changes_requested`, `reviewRequest.points` lists what to fix, one point each. A point has an `id`, a `title`, a `detail` and the `answer` type it expects: `text`, `link`, `file` or `link_or_file`. Reply to all points in a single `PATCH /v1/branded-calling/identities/{id}`, putting the replies in `reviewAnswers` under each point's id along with any fields you edited. Points left unanswered come back. ```json { "logoUrl": "https://acmeplumbing.example/logo-official.png", "reviewAnswers": { "p1": { "text": "Changed to morgan@coleaccounting.example, the firm address." }, "p2": { "url": "https://acmeplumbing.example/logo-official.png" } } } ``` ## Fix a rejection - **Rejected in Pozy review** (before filing): `reviewNote` explains why and `rejectionReasons` is empty. Impersonation, prohibited content and businesses that cannot be verified stop here. Fix the fields and `PATCH`. - **Rejected by the carrier**: `rejectionReasons` lists each reason with a `code`, `title`, `detail`, and a `message` from the vetting team on the first entry. `PATCH` the named fields and the identity goes straight back to `in_review`. Rejections and resubmissions cost nothing. ## Renewal A verified identity lasts one year (`expiringAt`). Pozy resubmits it 30 days before that date, and the branding stays live during the renewal review. You only see `expired` if renewal failed. Changing a verified identity's display name, logo or call reasons returns it to vetting. The monthly fee stops until it is verified once more. ## Let the business fill it in > **Not available yet:** > > Hosted identity forms are not available yet: `POST /v1/branded-calling/share` answers `403` with `code: "feature_not_available"`. When the business behind the identity is your customer, `POST /v1/branded-calling/share` returns a single-use link (valid 7 days) where they complete the identity themselves, with no Pozy login. Scope the link with an `identityId` (to finish or correct one), an `enterpriseId` (to add a new identity to a registered business), or neither (the business registers itself and its first identity together). What they submit arrives under your workspace as `requested` and goes through the same review. ## Remove numbers or the identity - `DELETE /v1/branded-calling/identities/{id}/numbers` with `phoneNumbers` (E.164, up to 100) detaches numbers. They can then join another identity. - `DELETE /v1/branded-calling/identities/{id}` detaches every number, removes the identity at the carrier and stops the monthly fee. It returns `409` while a claim is open. ## Errors | Status | Code | Cause | Fix | | --- | --- | --- | --- | | `422` | `feature_not_available` | The business is not in the US or Canada, or one of the numbers is not an active US number | Register a US or Canadian business, with active US numbers | | `409` | `invalid_resource_state` | The identity's status does not allow the action (editing outside `requested`, `changes_requested` or `rejected`, attaching before `verified`, a number already on an identity, deleting during a claim) | Check `status` first | | `400` | | The 6-digit code is incorrect or too old | Ask for another code | | `403` | `usage_billing_required` | The workspace has no payment method | Add one | | `422` | | `logoUrl` could not be downloaded or is not an image | Use a reachable HTTPS image | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/branded-calling/enterprises`](https://pozy.ai/docs/api/listBrandedCallingEnterprises) | List registered businesses | | [`POST /v1/branded-calling/enterprises`](https://pozy.ai/docs/api/createBrandedCallingEnterprise) | Register a business for Branded Calling | | [`GET /v1/branded-calling/enterprises/{id}`](https://pozy.ai/docs/api/getBrandedCallingEnterprise) | Get a registered business | | [`DELETE /v1/branded-calling/enterprises/{id}`](https://pozy.ai/docs/api/deleteBrandedCallingEnterprise) | Delete a registered business | | [`POST /v1/branded-calling/share`](https://pozy.ai/docs/api/shareBrandedCallingIdentityForm) | Create a caller identity share link (not available yet) | | [`GET /v1/branded-calling/call-reasons`](https://pozy.ai/docs/api/listBrandedCallingCallReasons) | List pre-approved call reasons | | [`GET /v1/branded-calling/identities`](https://pozy.ai/docs/api/listBrandedCallingIdentities) | List caller identities | | [`POST /v1/branded-calling/identities`](https://pozy.ai/docs/api/createBrandedCallingIdentity) | Create a caller identity (not available yet) | | [`POST /v1/branded-calling/identities/preflight`](https://pozy.ai/docs/api/preflightBrandedCallingIdentity) | Dry-run a caller identity before creating it | | [`GET /v1/branded-calling/identities/{id}`](https://pozy.ai/docs/api/getBrandedCallingIdentity) | Get a caller identity | | [`PATCH /v1/branded-calling/identities/{id}`](https://pozy.ai/docs/api/updateBrandedCallingIdentity) | Edit or resubmit a caller identity | | [`DELETE /v1/branded-calling/identities/{id}`](https://pozy.ai/docs/api/deleteBrandedCallingIdentity) | Delete a caller identity | | [`POST /v1/branded-calling/identities/{id}/verify-email`](https://pozy.ai/docs/api/resendBrandedCallingAuthorizerCode) | Resend the authorizer's code | | [`POST /v1/branded-calling/identities/{id}/verify-email/confirm`](https://pozy.ai/docs/api/confirmBrandedCallingAuthorizerEmail) | Confirm the authorizer's code | | [`GET /v1/branded-calling/identities/{id}/numbers`](https://pozy.ai/docs/api/listBrandedCallingIdentityNumbers) | List the numbers on a caller identity | | [`POST /v1/branded-calling/identities/{id}/numbers`](https://pozy.ai/docs/api/attachBrandedCallingNumbers) | Attach numbers to a verified identity | | [`DELETE /v1/branded-calling/identities/{id}/numbers`](https://pozy.ai/docs/api/detachBrandedCallingNumbers) | Detach numbers from an identity | ## Webhook events | Event | When it fires | | --- | --- | | [`branded_calling.identity.status_updated`](https://pozy.ai/docs/webhooks/events#branded-calling-identity-status-updated) | Caller identity status updated. | | [`branded_calling.identity.action_required`](https://pozy.ai/docs/webhooks/events#branded-calling-identity-action-required) | Caller identity action required. | | [`branded_calling.number.status_updated`](https://pozy.ai/docs/webhooks/events#branded-calling-number-status-updated) | Branded number status updated. | --- # Google Business management Run Google Business Profile listings through Pozy, from one location to hundreds: posts, reviews, hours, photos, services, menus and verification. A Google Business Profile listing is what people see when they search for a business on Google Search and Maps: the hours, the photos, the reviews, the "Order" and "Book" buttons. Pozy lets you manage all of it through the API, for one shop or a chain of locations, with the same account you use to publish posts there. ## What you can do | Job | Page | | --- | --- | | Publish updates, events and offers that appear on the listing | [Posts](https://pozy.ai/docs/google-business/posts) | | Read and answer reviews, and get an event when a new one arrives | [Reviews](https://pozy.ai/docs/google-business/reviews) | | Change hours, holiday hours, description, phone, website and categories | [Location details](https://pozy.ai/docs/google-business/locations) | | Set amenities such as Wi-Fi or outdoor seating, and the Book or Order buttons | [Location details](https://pozy.ai/docs/google-business/locations#attributes) | | Add and remove photos and videos | [Photos and videos](https://pozy.ai/docs/google-business/media) | | Keep the service list and food menus current | [Services and menus](https://pozy.ai/docs/google-business/services-menus) | | Verify a new or unverified location | [Verification](https://pozy.ai/docs/google-business/verifications) | | Read views, calls, direction requests and search keywords | [Platform insights](https://pozy.ai/docs/analytics/platform-insights) | Connection details, character limits and media rules for posting are on the [Google Business Profile platform page](https://pozy.ai/docs/platforms/google-business-profile). ## How accounts map to locations You connect Google Business Profile with Google sign-in on a [profile](https://pozy.ai/docs/build/profiles). The person picks a location, and that location becomes one Pozy account (`platform: "googlebusiness"`). - **One account, one selected location.** Every `/v1/accounts/{accountId}/gmb-*` endpoint acts on the account's selected location. - **Other locations under the same Google login.** Pass `locationId` as a query parameter to target any other location the login manages, without connecting it separately. - **Several locations at connect time.** In a [headless connect](https://pozy.ai/docs/connect/headless), [`POST /v1/connect/googlebusiness/select-location`](https://pozy.ai/docs/api/selectGoogleBusinessLocation) accepts a `locations` array and creates one account per location. Any that could not be connected come back in `failed`. ## Work with many locations ### List the locations [`GET /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/getGmbLocations) returns up to 100 locations by default (raise `limit` up to 500, filter with `search`) and the one currently selected. ```bash cURL curl "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-locations?search=lisbon" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-locations?search=lisbon", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const loc of data.locations) console.log(loc.id, loc.name, loc.address); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.get( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-locations", headers=H, params={"search": "lisbon"}, timeout=30, ) for loc in resp.json()["locations"]: print(loc["id"], loc["name"], loc["address"]) ``` ```json { "locations": [ { "id": "4821930576104832190", "name": "Padaria Rosa Chiado", "accountId": "accounts/118273645509", "accountName": "Padaria Rosa", "address": "Rua Garrett 12, Lisboa", "category": "Bakery" }, { "id": "5930184726391058277", "name": "Padaria Rosa Alfama", "accountId": "accounts/118273645509", "accountName": "Padaria Rosa", "address": "Rua dos Remédios 40, Lisboa", "category": "Bakery" } ], "hasMore": false, "selectedLocationId": "4821930576104832190", "cached": true } ``` ### Target a location Pass `?locationId=5930184726391058277` on any `gmb-*` call. When posting, put `locationId: "locations/5930184726391058277"` in `platformSpecificData`, one `platforms` entry per location ([multi-location posts](https://pozy.ai/docs/google-business/posts#post-to-several-locations)). ### Change the default (optional) [`PUT /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/updateGmbLocation) with `selectedLocationId` changes which location the account uses when you leave `locationId` out. Include `googleAccountId` (the `accountId` resource name from the list); large Google accounts need it. ### Give a location its own profile (optional) Agencies often keep one Pozy profile per client. [`POST /v1/accounts/{accountId}/gmb-locations/assign`](https://pozy.ai/docs/api/assignGoogleBusinessLocation) creates a new account for one location on another profile, reusing the existing Google sign-in. No second consent screen is needed. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-locations/assign \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "6717f4c9b5d8a03e6f2c7d19", "selectedLocationId": "locations/5930184726391058277", "googleAccountId": "accounts/118273645509" }' ``` The new account's `selectedLocationId` comes back as bare digits. Post to it with its own `accountId` and no `locationId`. ## Verified locations only Google only shows reviews, accepts most edits and returns full listing data for locations that are verified and matched to a published Maps place. Google calls this Voice of Merchant. Before you build anything on a location, read [`GET /v1/accounts/{accountId}/gmb-verifications`](https://pozy.ai/docs/api/getGoogleBusinessVerifications) and check `voiceOfMerchantState.hasVoiceOfMerchant`. If it is `false`, start with [Verification](https://pozy.ai/docs/google-business/verifications). ## Errors you will see everywhere Every `gmb-*` endpoint passes Google's own complaint through when Google rejects a request. | Status | Meaning | Fix | | --- | --- | --- | | `400` with code `gbp_bad_request` | Google rejected a field, a mask or a value | Read `error`, fix the field and retry | | `401` with code `token_invalid` | Google revoked or expired the sign-in | [Reconnect](https://pozy.ai/docs/connect/account-health) the account | | `403` | The Google login has no permission on this location | Ask an owner or manager of the listing to connect | | `404` | The `accountId` is not one of your accounts | Check the id | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/getGmbLocations) | List Google Business Profile locations | | [`PUT /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/updateGmbLocation) | Update Google Business Profile location | | [`POST /v1/accounts/{accountId}/gmb-locations/assign`](https://pozy.ai/docs/api/assignGoogleBusinessLocation) | Assign Google Business Profile location to another profile | | [`GET /v1/connect/googlebusiness/locations`](https://pozy.ai/docs/api/listGoogleBusinessLocations) | List Google Business Profile locations | | [`POST /v1/connect/googlebusiness/select-location`](https://pozy.ai/docs/api/selectGoogleBusinessLocation) | Select Google Business Profile location | | [`GET /v1/analytics/googlebusiness/performance`](https://pozy.ai/docs/api/getGoogleBusinessPerformance) | Get Google Business Profile performance metrics | | [`GET /v1/analytics/googlebusiness/search-keywords`](https://pozy.ai/docs/api/getGoogleBusinessSearchKeywords) | Get Google Business Profile search keywords | ## Webhook events | Event | When it fires | | --- | --- | | [`account.connected`](https://pozy.ai/docs/webhooks/events#account-connected) | Account connected. | | [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | --- # Location details Read and update a Google Business Profile listing: hours, holiday hours, description, contact details, categories, attributes and action links. Location details are the facts on a Google listing: opening hours, the holiday closures, the description, phone and website, the categories, the amenities and the buttons that let people book or order. Pozy reads and writes them for the account's selected location, or for any other location the same Google login manages when you pass `locationId`. Google only accepts most edits on a verified location. Check [verification](https://pozy.ai/docs/google-business/verifications) first if updates do not stick. ## Read the listing [`GET /v1/accounts/{accountId}/gmb-location-details`](https://pozy.ai/docs/api/getGoogleBusinessLocationDetails) returns the fields you name in `readMask`. Ask for only what you need. ```bash cURL curl "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details?readMask=title,regularHours,specialHours,profile,websiteUri,phoneNumbers" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details?readMask=title,regularHours,specialHours,profile,websiteUri,phoneNumbers", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.location?.reviewUrl, data.regularHours); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.get( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details", headers=H, params={"readMask": "title,regularHours,specialHours,profile,websiteUri,phoneNumbers"}, timeout=30, ) print(resp.json()) ``` ```json { "success": true, "accountId": "6713b0e5d1f4c69a2b8e3f75", "locationId": "4821930576104832190", "location": { "name": "Padaria Rosa Chiado", "placeId": "ChIJ-example-place-id", "reviewUrl": "https://search.google.com/local/writereview?placeid=ChIJ-example-place-id", "mapsUri": "https://maps.google.com/maps?cid=7349201837465019283", "isVerified": true }, "title": "Padaria Rosa Chiado", "regularHours": { "periods": [ { "openDay": "MONDAY", "openTime": "07:30", "closeDay": "MONDAY", "closeTime": "19:00" } ] }, "profile": { "description": "Sourdough, pastéis de nata and coffee since 1998." }, "websiteUri": "https://example.com" } ``` The `location` block is always included and is read-only. `reviewUrl` is the public "write a review" link, which is handy to print on receipts or behind a QR code. For a new or unverified location Google leaves out `placeId`, `reviewUrl` and `mapsUri`, so they come back `null` and `isVerified` is `false`. Fields you can name in `readMask`: | Field | Holds | | --- | --- | | `title` | The business name shown on Google | | `regularHours` | Weekly opening hours | | `specialHours` | Holiday and one-off hours | | `moreHours` | Extra hour sets, such as delivery or kitchen hours | | `profile` | `description` | | `websiteUri` | Website | | `phoneNumbers` | `primaryPhone` and `additionalPhones` | | `categories` | `primaryCategory` and `additionalCategories` | | `storefrontAddress` | The street address | | `serviceArea` | Areas served, for businesses that travel to customers | | `serviceItems` | Services (also editable on [Services and menus](https://pozy.ai/docs/google-business/services-menus)) | | `openInfo` | `OPEN`, `CLOSED_TEMPORARILY` or `CLOSED_PERMANENTLY`, plus the opening date | | `name`, `metadata` | Google's identifiers and metadata | Do not put `location` in `readMask`. It is a response-only block and Google rejects the request with `400`. ## Update the listing [`PUT /v1/accounts/{accountId}/gmb-location-details`](https://pozy.ai/docs/api/updateGoogleBusinessLocationDetails) changes only the fields named in `updateMask`. Fields you send but leave out of the mask are ignored, and the call returns `400` without a mask. ### Weekly hours Send every opening period for the week. Days with no period show as closed. ```bash cURL curl -X PUT https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "updateMask": "regularHours", "regularHours": { "periods": [ { "openDay": "MONDAY", "openTime": "07:30", "closeDay": "MONDAY", "closeTime": "19:00" }, { "openDay": "TUESDAY", "openTime": "07:30", "closeDay": "TUESDAY", "closeTime": "19:00" }, { "openDay": "SATURDAY", "openTime": "08:00", "closeDay": "SATURDAY", "closeTime": "14:00" } ] } }' ``` ### Holiday hours `specialHours` overrides the weekly hours on specific dates. Mark a day `closed`, or give it its own `openTime` and `closeTime`. ```bash cURL curl -X PUT https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "updateMask": "specialHours", "specialHours": { "specialHourPeriods": [ { "startDate": { "year": 2026, "month": 12, "day": 25 }, "closed": true }, { "startDate": { "year": 2026, "month": 12, "day": 24 }, "openTime": "07:30", "closeTime": "13:00" } ] } }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details", { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ updateMask: "specialHours", specialHours: { specialHourPeriods: [ { startDate: { year: 2026, month: 12, day: 25 }, closed: true }, { startDate: { year: 2026, month: 12, day: 24 }, openTime: "07:30", closeTime: "13:00" }, ], }, }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python requests.put( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-location-details", headers=H, json={ "updateMask": "specialHours", "specialHours": { "specialHourPeriods": [ {"startDate": {"year": 2026, "month": 12, "day": 25}, "closed": True}, {"startDate": {"year": 2026, "month": 12, "day": 24}, "openTime": "07:30", "closeTime": "13:00"}, ] }, }, timeout=30, ).raise_for_status() ``` Sending `specialHours` replaces the whole list of special periods, so include the ones you want to keep. ### Several fields at once Name each field in the mask, comma-separated: ```json { "updateMask": "profile,websiteUri,phoneNumbers", "profile": { "description": "Sourdough, pastéis de nata and coffee since 1998. Gluten-free loaves on Fridays." }, "websiteUri": "https://example.com/chiado", "phoneNumbers": { "primaryPhone": "+351 21 000 0123" } } ``` To mark a temporary closure, update `openInfo` with `status: "CLOSED_TEMPORARILY"`. Set it back to `OPEN` when the location reopens. ## Attributes Attributes are the yes-or-no and pick-list facts Google shows on a listing: dine-in, takeout, delivery, Wi-Fi, outdoor seating, accepted cards and many more. Which ones exist depends on the location's category. 1. Find the valid attributes with [`GET /v1/accounts/{accountId}/gmb-attribute-metadata`](https://pozy.ai/docs/api/getGmbAttributeMetadata). It returns each attribute's name, display name, `valueType` and allowed values. Ask for one location (pass `locationId`, or nothing for the selected location), or for a whole category (`categoryName` starting with `categories/` plus `regionCode`, optionally `languageCode`). Both page with `pageToken`. 2. Read what is set now with [`GET /v1/accounts/{accountId}/gmb-attributes`](https://pozy.ai/docs/api/getGoogleBusinessAttributes). 3. Update with [`PUT /v1/accounts/{accountId}/gmb-attributes`](https://pozy.ai/docs/api/updateGoogleBusinessAttributes), listing every attribute you change in `attributeMask`. ```bash cURL curl -X PUT https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-attributes \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "attributeMask": "has_takeout,has_wifi,pay_credit_card_types_accepted", "attributes": [ { "name": "has_takeout", "values": [true] }, { "name": "has_wifi", "values": [true] }, { "name": "pay_credit_card_types_accepted", "repeatedEnumValue": { "setValues": ["visa", "mastercard"] } } ] }' ``` | `valueType` | How to send it | | --- | --- | | `BOOL` | `values: [true]` or `[false]` | | `ENUM` | `values: ["one_value"]` | | `REPEATED_ENUM` | `repeatedEnumValue` with `setValues` and `unsetValues` | | `URL` | `uriValues: [{ "uri": "https://..." }]` | An attribute the category does not offer returns `400`. ## Action links Action links are the buttons on the listing that take people to book an appointment, reserve a table, order food or shop online. | Call | Does | | --- | --- | | [`GET .../gmb-place-actions`](https://pozy.ai/docs/api/listGoogleBusinessPlaceActions) | Lists the links, up to 100 per page | | [`POST .../gmb-place-actions`](https://pozy.ai/docs/api/createGoogleBusinessPlaceAction) | Creates one with a `uri` and a `placeActionType` | | [`PATCH .../gmb-place-actions`](https://pozy.ai/docs/api/updateGoogleBusinessPlaceAction) | Changes the `uri` or type of the link named in `name` | | [`DELETE .../gmb-place-actions?name=...`](https://pozy.ai/docs/api/deleteGoogleBusinessPlaceAction) | Removes it | `placeActionType` is one of `APPOINTMENT`, `ONLINE_APPOINTMENT`, `DINING_RESERVATION`, `FOOD_ORDERING`, `FOOD_DELIVERY`, `FOOD_TAKEOUT` or `SHOP_ONLINE`. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-place-actions \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "uri": "https://example.com/order", "placeActionType": "FOOD_ORDERING" }' ``` The response returns the link's `name` (`locations/.../placeActionLinks/...`), which the update and delete calls take. A `PATCH` with neither `uri` nor `placeActionType` returns `400`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-attribute-metadata`](https://pozy.ai/docs/api/getGmbAttributeMetadata) | Get attribute metadata | | [`GET /v1/accounts/{accountId}/gmb-attributes`](https://pozy.ai/docs/api/getGoogleBusinessAttributes) | Get attributes | | [`PUT /v1/accounts/{accountId}/gmb-attributes`](https://pozy.ai/docs/api/updateGoogleBusinessAttributes) | Update attributes | | [`GET /v1/accounts/{accountId}/gmb-location-details`](https://pozy.ai/docs/api/getGoogleBusinessLocationDetails) | Get location details | | [`PUT /v1/accounts/{accountId}/gmb-location-details`](https://pozy.ai/docs/api/updateGoogleBusinessLocationDetails) | Update location details | | [`GET /v1/accounts/{accountId}/gmb-place-actions`](https://pozy.ai/docs/api/listGoogleBusinessPlaceActions) | List action links | | [`POST /v1/accounts/{accountId}/gmb-place-actions`](https://pozy.ai/docs/api/createGoogleBusinessPlaceAction) | Create action link | | [`PATCH /v1/accounts/{accountId}/gmb-place-actions`](https://pozy.ai/docs/api/updateGoogleBusinessPlaceAction) | Update action link | | [`DELETE /v1/accounts/{accountId}/gmb-place-actions`](https://pozy.ai/docs/api/deleteGoogleBusinessPlaceAction) | Delete action link | --- # Google reviews List Google Business Profile reviews, reply and remove replies, read reviews across many locations, and get an event when a review arrives or changes. Reviews are the most visible part of a Google listing, and answering them quickly helps both ratings and local ranking. Pozy gives you two ways to work with them: the shared [reviews inbox](https://pozy.ai/docs/inbox/reviews), which mixes Google and Facebook reviews from every account, and Google-specific endpoints with extras such as the average rating and multi-location batches. Both write to the same reviews on Google. Google only returns reviews for verified locations matched to a Maps place. If a listing shows reviews on Google but Pozy returns none, check [verification](https://pozy.ai/docs/google-business/verifications). ## Find reviews that need an answer The reviews inbox is usually the easiest start. [`GET /v1/inbox/reviews`](https://pozy.ai/docs/api/listInboxReviews) filters by `platform`, `accountId`, `profileId`, rating range and `hasReply`, sorts by `date` or `rating`, and pages with `cursor` (up to 50 per page). ```bash cURL curl "https://api.pozy.ai/v1/inbox/reviews?platform=googlebusiness&accountId=6713b0e5d1f4c69a2b8e3f75&hasReply=false&maxRating=3" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/inbox/reviews?platform=googlebusiness&accountId=6713b0e5d1f4c69a2b8e3f75&hasReply=false&maxRating=3", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const r of data.data) console.log(r.rating, r.reviewer.name, r.text); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.get( "https://api.pozy.ai/v1/inbox/reviews", headers=H, params={"platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "hasReply": "false", "maxRating": 3}, timeout=30, ) for r in resp.json()["data"]: print(r["rating"], r["reviewer"]["name"], r["text"]) ``` ```json { "id": "accounts/118273645509/locations/4821930576104832190/reviews/AbFvOqn3k2Xe", "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "locationId": "4821930576104832190", "locationName": "Padaria Rosa Chiado", "reviewer": { "id": null, "name": "Tomás Ferreira", "profileImage": "https://lh3.googleusercontent.com/a/example" }, "rating": 3, "text": "Great bread, but the queue on Saturday was 20 minutes.", "created": "2026-10-26T10:12:00Z", "hasReply": false, "reply": null } ``` The review `id` is Google's full resource name, so it also tells you which location the review belongs to. ## Reply to a review Reply through the inbox with [`POST /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToInboxReview). The `reviewId` contains slashes, so URL-encode it in the path. ```bash cURL REVIEW_ID="accounts%2F118273645509%2Flocations%2F4821930576104832190%2Freviews%2FAbFvOqn3k2Xe" curl -X POST "https://api.pozy.ai/v1/inbox/reviews/$REVIEW_ID/reply" \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-AbFvOqn3k2Xe" \ -d '{ "accountId": "6713b0e5d1f4c69a2b8e3f75", "message": "Thanks, Tomás. We have added a second till on Saturdays, so the wait should be much shorter now." }' ``` ```ts Node const reviewId = "accounts/118273645509/locations/4821930576104832190/reviews/AbFvOqn3k2Xe"; const res = await fetch(`https://api.pozy.ai/v1/inbox/reviews/${reviewId}/reply`, { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "reply-AbFvOqn3k2Xe", "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6713b0e5d1f4c69a2b8e3f75", message: "Thanks, Tomás. We have added a second till on Saturdays, so the wait should be much shorter now.", }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python from urllib.parse import quote review_id = "accounts/118273645509/locations/4821930576104832190/reviews/AbFvOqn3k2Xe" requests.post( f"https://api.pozy.ai/v1/inbox/reviews/{quote(review_id, safe='')}/reply", headers={**H, "Idempotency-Key": "reply-AbFvOqn3k2Xe"}, json={ "accountId": "6713b0e5d1f4c69a2b8e3f75", "message": "Thanks, Tomás. We have added a second till on Saturdays, so the wait should be much shorter now.", }, timeout=30, ).raise_for_status() ``` > **One reply per review:** > > Google keeps a single owner reply on each review. Replying again replaces the earlier reply, including one somebody typed by hand on Google. Read `hasReply` before you answer, and use an `Idempotency-Key` so a retry after a timeout does not post twice. To remove a reply and leave the review in place, call [`DELETE /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteInboxReviewReply) with the `accountId`. ## Google-specific review endpoints These work on one Google account and take Google's own field names. | Call | Use it for | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-reviews`](https://pozy.ai/docs/api/getGoogleBusinessReviews) | One location's reviews plus `averageRating` and `totalReviewCount`. Pages with `pageToken`, up to 50 per page. Add `locationId` for another location. | | [`GET .../gmb-reviews/{reviewId}`](https://pozy.ai/docs/api/getGoogleBusinessReview) | One review, with `starRating` (`ONE` to `FIVE`), `comment` and `reviewReply` | | [`POST .../gmb-reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToGoogleBusinessReview) | Reply with a `comment` | | [`DELETE .../gmb-reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteGoogleBusinessReviewReply) | Remove the reply | | [`POST .../gmb-reviews/batch`](https://pozy.ai/docs/api/batchGetGoogleBusinessReviews) | Reviews from up to 50 locations in one call | ### Reviews across many locations For a chain, the batch call saves one request per location. Send `locationNames` in the form `accounts/{googleAccountId}/locations/{locationId}`, built from the [locations list](https://pozy.ai/docs/google-business#work-with-many-locations). ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-reviews/batch \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "locationNames": [ "accounts/118273645509/locations/4821930576104832190", "accounts/118273645509/locations/5930184726391058277" ], "pageSize": 50, "orderBy": "updateTime desc" }' ``` The response is a flat `locationReviews` list, each item with the location `name` and the `review`. Results come newest first by default (`orderBy` can also be `rating` or `rating desc`), so you can stop paging once you pass the dates you care about. Send `nextPageToken` back as `pageToken` in the next body; no token means the last page. The batch does not return averages or totals; use the single-location call for those. ## Get told about new reviews Subscribe a [webhook](https://pozy.ai/docs/webhooks) to the review events instead of polling. | Event | Fires when | | --- | --- | | `review.new` | A new review is posted | | `review.updated` | The reviewer edits the text or the rating | | `review.deleted` | Google stops returning the review, for example after its spam filter removed it. This usually arrives 6 to 12 hours after the review disappears. | | `review.restored` | A review reported as deleted comes back | A review Google hides and later restores produces both `review.deleted` and `review.restored`. Pozy only checks for deletions on locations covered by a webhook subscribed to `review.deleted` or `review.restored`, so subscribe to them if you need that signal. A common setup: on `review.new` with a rating of 4 or 5, send an automatic thank-you; on 3 or below, notify a person to answer. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/inbox/reviews`](https://pozy.ai/docs/api/listInboxReviews) | List reviews | | [`POST /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToInboxReview) | Reply to review | | [`DELETE /v1/inbox/reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteInboxReviewReply) | Delete review reply | | [`GET /v1/accounts/{accountId}/gmb-reviews`](https://pozy.ai/docs/api/getGoogleBusinessReviews) | Get reviews | | [`POST /v1/accounts/{accountId}/gmb-reviews/batch`](https://pozy.ai/docs/api/batchGetGoogleBusinessReviews) | Batch get reviews | | [`GET /v1/accounts/{accountId}/gmb-reviews/{reviewId}`](https://pozy.ai/docs/api/getGoogleBusinessReview) | Get a review | | [`POST /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply`](https://pozy.ai/docs/api/replyToGoogleBusinessReview) | Reply to a review | | [`DELETE /v1/accounts/{accountId}/gmb-reviews/{reviewId}/reply`](https://pozy.ai/docs/api/deleteGoogleBusinessReviewReply) | Delete a review reply | ## Webhook events | Event | When it fires | | --- | --- | | [`review.new`](https://pozy.ai/docs/webhooks/events#review-new) | Review new. | | [`review.updated`](https://pozy.ai/docs/webhooks/events#review-updated) | Review updated. | | [`review.deleted`](https://pozy.ai/docs/webhooks/events#review-deleted) | Review deleted. | | [`review.restored`](https://pozy.ai/docs/webhooks/events#review-restored) | Review restored. | --- # Google Business posts Publish updates, events and offers to a Google Business Profile location, add a call-to-action button, post to many locations at once and edit published posts. Posts on a Google listing show up in Search and Maps next to the business, not in a feed. They are a good place for this week's special, an event or a discount code. You publish them with the same [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) call as every other platform, with `platform: "googlebusiness"` and a few Google-only settings in `platformSpecificData`. ## Post types | `topicType` | Use it for | Needs | | --- | --- | --- | | `STANDARD` (default) | News, updates, a new product | Nothing extra | | `EVENT` | Anything with dates: an opening, a workshop, live music | An `event` with `title` and `schedule` | | `OFFER` | Promotions and discounts | An `offer`; an `event` is optional and sets the offer's validity period | Any type can add a `callToAction` button. ## Publish an update ```bash cURL curl -X POST https://api.pozy.ai/v1/posts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Pumpkin and walnut loaves are back for the autumn. Baked fresh every morning from 7:30.", "mediaItems": [{ "type": "image", "url": "https://pozy.ai/media/autumn-loaf.jpg" }], "platforms": [{ "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": { "callToAction": { "type": "ORDER", "url": "https://example.com/order" } } }], "publishNow": true }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: "Pumpkin and walnut loaves are back for the autumn. Baked fresh every morning from 7:30.", mediaItems: [{ type: "image", url: "https://pozy.ai/media/autumn-loaf.jpg" }], platforms: [{ platform: "googlebusiness", accountId: "6713b0e5d1f4c69a2b8e3f75", platformSpecificData: { callToAction: { type: "ORDER", url: "https://example.com/order" } }, }], publishNow: true, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.post( "https://api.pozy.ai/v1/posts", headers=H, json={ "content": "Pumpkin and walnut loaves are back for the autumn. Baked fresh every morning from 7:30.", "mediaItems": [{"type": "image", "url": "https://pozy.ai/media/autumn-loaf.jpg"}], "platforms": [{ "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": {"callToAction": {"type": "ORDER", "url": "https://example.com/order"}}, }], "publishNow": True, }, timeout=30, ) print(resp.status_code, resp.json()) ``` To schedule it, replace `publishNow` with `scheduledFor` and a `timezone` ([Scheduling](https://pozy.ai/docs/publishing/scheduling)). Leave out `mediaItems` for a text-only post, though posts with an image or a button get more attention. ### Call-to-action buttons | `callToAction.type` | Button | | --- | --- | | `LEARN_MORE` | Links to more information | | `BOOK` | Booking or reservation page | | `ORDER` | Online ordering | | `SHOP` | Online shop | | `SIGN_UP` | Registration or newsletter | | `CALL` | Calls the business | `url` must be a working HTTPS link. Avoid link shorteners; Google may not follow them. ## Publish an event Set `topicType: "EVENT"` and an `event` with a `title` and a `schedule`. Dates are `{ year, month, day }` objects and times are `{ hours, minutes }` in 24-hour time. Times are optional. Pozy also accepts ISO 8601 strings for all four and converts them. ```json { "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": { "topicType": "EVENT", "event": { "title": "Sourdough workshop", "schedule": { "startDate": { "year": 2026, "month": 11, "day": 14 }, "startTime": { "hours": 10, "minutes": 0 }, "endDate": { "year": 2026, "month": 11, "day": 14 }, "endTime": { "hours": 13, "minutes": 0 } } }, "callToAction": { "type": "SIGN_UP", "url": "https://example.com/workshop" } } } ``` Google shows the title and dates as the heading of the post. ## Publish an offer Set `topicType: "OFFER"` and an `offer` with any of `couponCode`, `redeemOnlineUrl` and `termsConditions`. All three are optional, but an offer with none of them gives people nothing to act on. Add an `event` to give the offer a heading and an end date. ```json { "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": { "topicType": "OFFER", "event": { "title": "10% off all cakes", "schedule": { "startDate": { "year": 2026, "month": 12, "day": 1 }, "endDate": { "year": 2026, "month": 12, "day": 23 } } }, "offer": { "couponCode": "XMAS10", "redeemOnlineUrl": "https://example.com/cakes", "termsConditions": "Orders placed by 23 December. One code per order." } } } ``` ## Post to several locations If one Google login manages several locations, you can post to all of them in one request. Repeat the `platforms` entry with the same `accountId` and a different `locationId` (`locations/` followed by the location id from [`GET .../gmb-locations`](https://pozy.ai/docs/api/getGmbLocations)). An entry without `locationId` goes to the account's selected location. ```json { "content": "Both shops are open on the holiday Monday, 8:00 to 14:00.", "platforms": [ { "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": { "locationId": "locations/4821930576104832190" } }, { "platform": "googlebusiness", "accountId": "6713b0e5d1f4c69a2b8e3f75", "platformSpecificData": { "locationId": "locations/5930184726391058277" } } ], "publishNow": true } ``` The response has one `platforms` entry per location, each with its own status and link. ## Edit a published post [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) with `platform: "googlebusiness"` and the new `content` changes the text of a live post. There is no edit window and no limit on edits, and the post keeps its id. Only the text changes: the image, button, event and offer details stay as published. To change those, publish a new post. ```bash cURL curl -X POST https://api.pozy.ai/v1/posts/6718a5d0c6e9b14f7a3d8e2a/edit \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "googlebusiness", "content": "Pumpkin and walnut loaves are back. Baked fresh every morning from 7:30, while they last." }' ``` If the post went to two accounts, add `accountId` to pick one. A post sent to several locations from one account always edits that account's first entry. A `404` means the post no longer exists on Google: it was deleted on Google, or it is an event or offer whose end date has passed. ## Limits | Property | Limit | | --- | --- | | Text | 1,500 characters | | Images | 1 per post | | Video | Not supported | | Image format | JPEG or PNG. WebP is converted for you. GIF is rejected. | | Image size | Up to 5 MB. Larger images are compressed before sending. | | Image dimensions | At least 400 x 300 px. 1200 x 900 px (4:3) crops best. | | `languageCode` | Optional BCP 47 code. It labels the post and does not translate it. | Image URLs must be public HTTPS links that return the file itself. [Upload media](https://pozy.ai/docs/publishing/media) to get one. Google does not offer per-post analytics. Use location-level metrics from [Platform insights](https://pozy.ai/docs/analytics/platform-insights) instead. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `207` with `post.status: "failed"` | The post was saved but Google rejected it. The reason is in `platforms[].errorMessage`. | Fix and create a new post. A failed post is not retried. | | "Request contains an invalid argument" on an event | `topicType: "EVENT"` without an `event`, or a schedule missing a date | Add `event.title`, `startDate` and `endDate` | | "Image too small" | Under 400 x 300 px | Use a larger image | | "Invalid image format" | Not JPEG or PNG, or a corrupt file | Re-export as JPEG or PNG | | Post published but not visible | Google is reviewing it, or the location is not verified | Allow up to 48 hours and check [verification](https://pozy.ai/docs/google-business/verifications) | > **207 counts as success in most HTTP clients:** > > `207` is a 2xx status, so `fetch(...).ok` is `true`. Check the status code and `post.status` before you report success. ## Endpoints | Endpoint | What it does | | --- | --- | | [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post | | [`POST /v1/posts/{postId}/edit`](https://pozy.ai/docs/api/editPost) | Edit published post | | [`GET /v1/posts/{postId}`](https://pozy.ai/docs/api/getPost) | Get post | | [`GET /v1/accounts/{accountId}/gmb-locations`](https://pozy.ai/docs/api/getGmbLocations) | List Google Business Profile locations | ## Webhook events | Event | When it fires | | --- | --- | | [`post.published`](https://pozy.ai/docs/webhooks/events#post-published) | Post published. | | [`post.failed`](https://pozy.ai/docs/webhooks/events#post-failed) | Post failed. | | [`post.partial`](https://pozy.ai/docs/webhooks/events#post-partial) | Post partial. | | [`post.platform.published`](https://pozy.ai/docs/webhooks/events#post-platform-published) | Post platform published. | | [`post.platform.failed`](https://pozy.ai/docs/webhooks/events#post-platform-failed) | Post platform failed. | | [`post.platform.deleted`](https://pozy.ai/docs/webhooks/events#post-platform-deleted) | Post platform deleted. | --- # Photos and videos List, add and remove the photos and videos on a Google Business Profile listing, and choose where each one appears. Photos are often the first thing people look at on a listing: the shop front, the counter, the food. Pozy lets you add them from a public URL, label where they belong (cover, logo, interior, menu and so on) and remove old ones. This is separate from the single image you attach to a [post](https://pozy.ai/docs/google-business/posts); media added here lives on the listing itself. ## List what is on the listing [`GET /v1/accounts/{accountId}/gmb-media`](https://pozy.ai/docs/api/listGoogleBusinessMedia) returns up to 100 items per page with `pageToken` for the next page, plus `totalMediaItemsCount`. ```bash cURL curl "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media?pageSize=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media?pageSize=50", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const m of data.mediaItems) console.log(m.mediaFormat, m.locationAssociation?.category, m.googleUrl); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} resp = requests.get( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media", headers=H, params={"pageSize": 50}, timeout=30, ) for m in resp.json()["mediaItems"]: print(m["mediaFormat"], m.get("locationAssociation", {}).get("category"), m["googleUrl"]) ``` ```json { "success": true, "accountId": "6713b0e5d1f4c69a2b8e3f75", "locationId": "4821930576104832190", "mediaItems": [ { "name": "accounts/118273645509/locations/4821930576104832190/media/AF1QipExample01", "mediaFormat": "PHOTO", "googleUrl": "https://lh3.googleusercontent.com/p/example-01", "thumbnailUrl": "https://lh3.googleusercontent.com/p/example-01=s200", "description": "Counter at opening time", "createTime": "2026-09-02T08:10:00Z", "locationAssociation": { "category": "INTERIOR" } } ], "totalMediaItemsCount": 23 } ``` Add `locationId` to any of these calls to work on another location under the same Google login. ## Add a photo or video [`POST /v1/accounts/{accountId}/gmb-media`](https://pozy.ai/docs/api/createGoogleBusinessMedia) takes a `sourceUrl` that Google can download, an optional `description` and a `category`. Set `mediaFormat` to `VIDEO` for a video; the default is `PHOTO`. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceUrl": "https://pozy.ai/media/shopfront-autumn.jpg", "description": "Shop front in the morning", "category": "EXTERIOR" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ sourceUrl: "https://pozy.ai/media/shopfront-autumn.jpg", description: "Shop front in the morning", category: "EXTERIOR", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.name, data.googleUrl); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media", headers=H, json={ "sourceUrl": "https://pozy.ai/media/shopfront-autumn.jpg", "description": "Shop front in the morning", "category": "EXTERIOR", }, timeout=30, ) print(resp.json()["googleUrl"]) ``` The URL must be public, need no login and return the file itself rather than a web page. If the file is on your computer, [upload it to Pozy](https://pozy.ai/docs/publishing/media) first and use the URL you get back. ## Pick the category `category` decides where Google shows the item. | Category | Where it shows | | --- | --- | | `COVER` | The main image at the top of the listing | | `PROFILE` | The listing's profile image | | `LOGO` | The business logo | | `EXTERIOR` | Outside views, the entrance, the street | | `INTERIOR` | Inside the premises | | `PRODUCT` | Products you sell | | `FOOD_AND_DRINK` | Dishes and drinks | | `MENU` | Photos of the menu | | `COMMON_AREA` | Shared spaces such as a lobby or terrace | | `ROOMS` | Rooms, for hotels and similar | | `TEAMS` | The people who work there | | `AT_WORK` | The team at work | | `ADDITIONAL` | Anything else | | `CATEGORY_UNSPECIFIED` | No category | ## Remove an item [`DELETE /v1/accounts/{accountId}/gmb-media?mediaId=...`](https://pozy.ai/docs/api/deleteGoogleBusinessMedia) removes one item. `mediaId` is the last segment of the item's `name` (`AF1QipExample01` in the example above). ```bash cURL curl -X DELETE "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-media?mediaId=AF1QipExample01" \ -H "Authorization: Bearer $POZY_API_KEY" ``` Photos that customers uploaded to the listing cannot be removed through the API. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` on upload | Google could not fetch the URL, or the format is not one it accepts | Use a public HTTPS link that returns a common photo or video file, and set `mediaFormat` to match | | `403` | The Google login cannot manage this location | Reconnect with an owner or manager of the listing | | `401` `token_invalid` | Google revoked the sign-in | Reconnect the account | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-media`](https://pozy.ai/docs/api/listGoogleBusinessMedia) | List media | | [`POST /v1/accounts/{accountId}/gmb-media`](https://pozy.ai/docs/api/createGoogleBusinessMedia) | Upload photo | | [`DELETE /v1/accounts/{accountId}/gmb-media`](https://pozy.ai/docs/api/deleteGoogleBusinessMedia) | Delete photo | --- # Services and food menus Keep the services a Google Business Profile location offers, and the menus of a restaurant or cafe, in sync with your own system. Google shows a list of services on many listings (a plumber's pipe repair, a salon's colour treatment) and full menus on restaurants and cafes. If your product already knows what a business sells, push it to Google with these endpoints instead of asking the owner to type it twice. Both work on verified locations only. ## Services [`GET /v1/accounts/{accountId}/gmb-services`](https://pozy.ai/docs/api/getGoogleBusinessServices) reads the service list. [`PUT /v1/accounts/{accountId}/gmb-services`](https://pozy.ai/docs/api/updateGoogleBusinessServices) replaces it. > **PUT replaces the whole list:** > > Google has no way to update one service. Every `PUT` must contain all the services you want to keep. Read the list, change it, send it back. Each service is one of two kinds, with an optional `price`: | Kind | Fields | When to use it | | --- | --- | --- | | `structuredServiceItem` | `serviceTypeId` from Google's catalog for the category, optional `description` | When Google already knows the service | | `freeFormServiceItem` | `category` (the location's category, such as `categories/gcid:hair_salon`) and a `label` with `displayName`, optional `description` and `languageCode` | Anything Google's catalog does not list | `price` uses Google's money format: `currencyCode`, whole `units` as a string, and optional `nanos` for the fraction (`500000000` is 0.50). ```bash cURL curl -X PUT https://api.pozy.ai/v1/accounts/6719c8e1a7f2b05d3e9c4f60/gmb-services \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "serviceItems": [ { "freeFormServiceItem": { "category": "categories/gcid:hair_salon", "label": { "displayName": "Cut and blow-dry", "description": "45 minutes, includes wash" } }, "price": { "currencyCode": "EUR", "units": "35" } }, { "freeFormServiceItem": { "category": "categories/gcid:hair_salon", "label": { "displayName": "Full colour", "description": "Up to 2 hours" } }, "price": { "currencyCode": "EUR", "units": "79", "nanos": 500000000 } } ] }' ``` ```ts Node const accountId = "6719c8e1a7f2b05d3e9c4f60"; // Read, modify, write back the full list const currentRes = await fetch(`https://api.pozy.ai/v1/accounts/${accountId}/gmb-services`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const current = await currentRes.json(); if (!currentRes.ok) throw new Error(current.error); const serviceItems = [ ...current.services, { freeFormServiceItem: { category: "categories/gcid:hair_salon", label: { displayName: "Fringe trim", description: "10 minutes, walk-ins welcome" }, }, price: { currencyCode: "EUR", units: "8" }, }, ]; const res = await fetch(`https://api.pozy.ai/v1/accounts/${accountId}/gmb-services`, { method: "PUT", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ serviceItems }), }); if (!res.ok) throw new Error((await res.json()).error); ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} url = "https://api.pozy.ai/v1/accounts/6719c8e1a7f2b05d3e9c4f60/gmb-services" services = requests.get(url, headers=H, timeout=30).json()["services"] services.append({ "freeFormServiceItem": { "category": "categories/gcid:hair_salon", "label": {"displayName": "Fringe trim", "description": "10 minutes, walk-ins welcome"}, }, "price": {"currencyCode": "EUR", "units": "8"}, }) requests.put(url, headers=H, json={"serviceItems": services}, timeout=30).raise_for_status() ``` The response returns the list as Google stored it. Keep that copy; it is the one to send back next time. The location's categories, which a free-form service must use, come from `categories` in [location details](https://pozy.ai/docs/google-business/locations). ## Food menus Restaurants, cafes and other places Google gives menu support to can publish their menus. [`GET /v1/accounts/{accountId}/gmb-food-menus`](https://pozy.ai/docs/api/getGoogleBusinessFoodMenus) reads them and [`PUT`](https://pozy.ai/docs/api/updateGoogleBusinessFoodMenus) updates them. Send the full `menus` array with `updateMask: "menus"`. A menu is a tree: menus contain sections, sections contain items, and items can have options. | Level | Fields | | --- | --- | | Menu | `labels` (name per language), `sections`, optional `cuisines` and `sourceUrl` | | Section | `labels`, `items` | | Item | `labels` (name and description), `attributes`, optional `options` | | Option | Its own `labels` and `attributes`, for variants such as sizes | Item `attributes`: | Attribute | Values | | --- | --- | | `price` | `currencyCode`, `units`, optional `nanos` | | `dietaryRestriction` | For example `VEGETARIAN`, `VEGAN`, `GLUTEN_FREE` | | `allergen` | For example `DAIRY`, `GLUTEN`, `SHELLFISH` | | `spiciness` | `MILD`, `MEDIUM` or `HOT` | | `servesNumPeople` | A whole number | | `preparationMethods` | Google's preparation method values | | `mediaKeys` | Photos of the item already on the listing | ```bash cURL curl -X PUT https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-food-menus \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "updateMask": "menus", "menus": [{ "labels": [{ "displayName": "Breakfast", "languageCode": "en" }], "sections": [{ "labels": [{ "displayName": "Pastries" }], "items": [{ "labels": [{ "displayName": "Pastel de nata", "description": "Custard tart, baked hourly" }], "attributes": { "price": { "currencyCode": "EUR", "units": "1", "nanos": 400000000 }, "dietaryRestriction": ["VEGETARIAN"] } }, { "labels": [{ "displayName": "Croissant" }], "attributes": { "price": { "currencyCode": "EUR", "units": "2" } }, "options": [ { "labels": [{ "displayName": "Plain" }], "attributes": { "price": { "currencyCode": "EUR", "units": "2" } } }, { "labels": [{ "displayName": "Ham and cheese" }], "attributes": { "price": { "currencyCode": "EUR", "units": "3", "nanos": 500000000 } } } ] }] }] }] }' ``` Menus only exist on locations Google gives menu support to. On other locations the call returns `400`. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` `gbp_bad_request` on services | Google rejected an item, often a category the location does not have or an unknown `serviceTypeId` | Use the location's own categories, or a free-form item | | `400` on menus | The location has no menu support, or the account has no location selected | Check the location's category and the selected location | | `403` | The Google login cannot manage this location | Reconnect with an owner or manager | | `401` `token_invalid` | Google revoked the sign-in | Reconnect the account | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-food-menus`](https://pozy.ai/docs/api/getGoogleBusinessFoodMenus) | Get food menus | | [`PUT /v1/accounts/{accountId}/gmb-food-menus`](https://pozy.ai/docs/api/updateGoogleBusinessFoodMenus) | Update food menus | | [`GET /v1/accounts/{accountId}/gmb-services`](https://pozy.ai/docs/api/getGoogleBusinessServices) | Get services | | [`PUT /v1/accounts/{accountId}/gmb-services`](https://pozy.ai/docs/api/updateGoogleBusinessServices) | Replace services | --- # Verify a location Check whether a Google Business Profile location is verified, see which methods Google offers, start a verification and submit the code. Google only fully trusts a listing once the business proves it is real. Until then reviews do not come through, many edits do not publish and some listing data is missing. Google calls a verified, published listing "Voice of Merchant". This page shows how to check that state and run a verification from your own product, so a new client never has to leave it to finish setup. ## Check the state [`GET /v1/accounts/{accountId}/gmb-verifications`](https://pozy.ai/docs/api/getGoogleBusinessVerifications) returns the location's Voice of Merchant state and its verification history. ```bash cURL curl https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); if (!data.voiceOfMerchantState.hasVoiceOfMerchant) { console.log("Not verified. Pending:", data.voiceOfMerchantState.verify?.hasPendingVerification); } ``` ```python Python import os import requests H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"} state = requests.get( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications", headers=H, timeout=30, ).json() print(state["voiceOfMerchantState"]) ``` ```json { "success": true, "accountId": "6713b0e5d1f4c69a2b8e3f75", "locationId": "4821930576104832190", "voiceOfMerchantState": { "hasVoiceOfMerchant": false, "hasBusinessAuthority": true, "verify": { "hasPendingVerification": false } }, "verifications": [ { "name": "locations/4821930576104832190/verifications/7Q2093841176", "method": "SMS", "state": "FAILED", "createTime": "2026-10-20T15:02:00Z" } ] } ``` | Field | Meaning | | --- | --- | | `hasVoiceOfMerchant` | `true` when the listing is verified and live on Google. You are done. | | `verify.hasPendingVerification` | When not verified: `true` if a verification is already in progress (waiting for a code), `false` if none has been started or the last one failed | | `verifications[].state` | `PENDING`, `COMPLETED` or `FAILED` for each attempt | A verified location also needs to be matched to a published Maps place before reviews appear. That shows up as a non-null `placeId` in [location details](https://pozy.ai/docs/google-business/locations). ## Run a verification ### See which methods Google offers [`POST .../gmb-verifications/options`](https://pozy.ai/docs/api/fetchGoogleBusinessVerificationOptions) with a `languageCode` lists the methods available for this location. Nothing is sent to the business yet. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications/options \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "languageCode": "pt-PT" }' ``` ```json { "success": true, "options": [ { "verificationMethod": "SMS", "phoneNumber": "+351210000123" }, { "verificationMethod": "PHONE_CALL", "phoneNumber": "+351210000123" }, { "verificationMethod": "ADDRESS" } ] } ``` A business that serves customers at their address and has no storefront (a service-area business) must also send `context` with its service address, or Google returns `400`. ### Start one [`POST .../gmb-verifications`](https://pozy.ai/docs/api/startGoogleBusinessVerification) with the chosen `method` and the matching detail. > **This contacts the business:** > > Starting a verification is a real-world action. Google mails a postcard, calls, or sends a text or email to the business right away. Confirm with the owner before you call it. | `method` | Send | What Google does | | --- | --- | --- | | `SMS` | `phoneNumber` from the options | Texts a code | | `PHONE_CALL` | `phoneNumber` from the options | Calls and reads a code | | `EMAIL` | `emailAddress` | Emails a code | | `ADDRESS` | `mailerContact` (the name on the postcard) | Mails a postcard with a code. This can take days. | | `AUTO` | Nothing | Verifies at once when Google allows it | | `VETTED_PARTNER` | Nothing | Used by Google's approved partners | ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "SMS", "languageCode": "pt-PT", "phoneNumber": "+351210000123" }' ``` The response returns the new `verification` with `state: "PENDING"`. The last segment of `verification.name` is the `verificationId`. ### Submit the code When the business has the code, send it to [`POST .../gmb-verifications/{verificationId}/complete`](https://pozy.ai/docs/api/completeGoogleBusinessVerification) as `pin`. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications/7Q2093841177/complete \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pin": "482913" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications/7Q2093841177/complete", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ pin: "482913" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.verification.state); // "COMPLETED" or "FAILED" ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/accounts/6713b0e5d1f4c69a2b8e3f75/gmb-verifications/7Q2093841177/complete", headers=H, json={"pin": "482913"}, timeout=30, ) print(resp.json()["verification"]["state"]) ``` `COMPLETED` means Google accepted the code. `FAILED` means it did not; start a new verification to try again. ## Build it into onboarding A simple flow for new locations: 1. After the account connects, read the verification state. 2. If `hasVoiceOfMerchant` is `true`, continue. 3. If a verification is pending, ask the owner for the code they received and submit it. 4. Otherwise fetch the options, let the owner pick one, start it, and ask for the code. Postcards can take days to arrive, so let the owner close your app and come back to step 3 later. ## Common errors | Error | Cause | Fix | | --- | --- | --- | | `400` on options | Missing `languageCode`, or a service-area business without `context` | Add the missing field | | `400` on complete | Wrong code, or the verification is no longer `PENDING` | Check the code, or start a new verification | | `403` | The Google login cannot manage this location | Reconnect with an owner or manager | | `401` `token_invalid` | Google revoked the sign-in | Reconnect the account | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/gmb-verifications`](https://pozy.ai/docs/api/getGoogleBusinessVerifications) | Get verification state | | [`POST /v1/accounts/{accountId}/gmb-verifications`](https://pozy.ai/docs/api/startGoogleBusinessVerification) | Start a verification | | [`POST /v1/accounts/{accountId}/gmb-verifications/options`](https://pozy.ai/docs/api/fetchGoogleBusinessVerificationOptions) | Fetch verification options | | [`POST /v1/accounts/{accountId}/gmb-verifications/{verificationId}/complete`](https://pozy.ai/docs/api/completeGoogleBusinessVerification) | Complete a verification | --- # Commerce & blogs Connect Shopify, WooCommerce, WordPress, Wix and Webflow to manage catalogs, read orders, publish blog articles and install ad pixels with one API. Stores and websites are accounts too. Connect a Shopify store, a WooCommerce shop or a WordPress, Wix or Webflow site to a profile, and you can manage its catalog, read its orders, publish its blog and put ad pixels on it, with the same API key and request shapes you use for social posting. Write the integration once and it works on every platform that supports the job. ## What each platform supports | Job | Shopify | WooCommerce | WordPress | Wix | Webflow | | --- | --- | --- | --- | --- | --- | | Products, variants, images, stock | Yes | Yes | Through WooCommerce | No | No | | Collections and discounts | Yes | Categories and coupons | Through WooCommerce | No | No | | Orders (read-only) | Yes | Yes | Through WooCommerce | No | No | | Store pages | Yes | Yes | Yes | No | No | | Menus, redirects, markets, metaobjects, sales channels | Yes | No | No | No | No | | Meta catalog sync | Yes | Yes | Through WooCommerce | No | No | | Blog articles | Yes | Via WordPress | Yes | Yes | Yes (CMS items) | | Native scheduling of articles | Yes | Via WordPress | Yes | Yes | No | | Ad pixels on the site | Yes, with storefront events | Via WordPress | Yes, page views | Yes, page views | Yes, page views | WooCommerce is not a separate connection: it is a self-hosted [WordPress](https://pozy.ai/docs/platforms/wordpress) site with WooCommerce installed, connected with an Application Password. Store objects from it report `platform: "woocommerce"`. ## Two APIs, one account - **Commerce API** at `/v1/commerce/...` covers the catalog, stock, orders, discounts and store content. Every call names the store with `accountId`: in the query on reads, in the body on writes. See [Products](https://pozy.ai/docs/commerce/products) and [Orders](https://pozy.ai/docs/commerce/orders). - **Blogs API** at `/v1/accounts/{accountId}/blogs/...` publishes articles. The same five calls work on Shopify, WordPress, Wix and Webflow. See [Blogs](https://pozy.ai/docs/commerce/blogs). Ad pixels use the tracking tag endpoints of your ads account and name the site as `storeAccountId`. See [Site pixels](https://pozy.ai/docs/commerce/site-pixels). ## Start with the store's capabilities Call `GET /v1/commerce/store` before anything else. It tells you the currency and country, what the store can do now, and what it could do once the owner grants more permissions. ```bash cURL curl "https://api.pozy.ai/v1/commerce/store?accountId=6710c4b2e4b0d2f1a8c95e3a" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/commerce/store?accountId=6710c4b2e4b0d2f1a8c95e3a", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); if (data.store.missingCapabilities.includes("orders.read")) { console.log("Ask the owner to approve:", data.store.grantPermissionsUrl); } ``` ```python Python import os, requests store = requests.get( "https://api.pozy.ai/v1/commerce/store", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6710c4b2e4b0d2f1a8c95e3a"}, ).json()["store"] print(store["capabilities"], store["grantPermissionsUrl"]) ``` ```json { "store": { "accountId": "6710c4b2e4b0d2f1a8c95e3a", "platform": "shopify", "name": "Acme", "domain": "acme.myshopify.com", "url": "https://acme.com", "currency": "USD", "country": "US", "capabilities": ["products.read", "products.create", "products.update", "collections.read", "pages.read", "pages.write"], "missingCapabilities": ["inventory.read", "inventory.write", "discounts.read", "orders.read"], "grantPermissionsUrl": "https://admin.shopify.com/store/acme/oauth/install?..." } } ``` On Shopify, send the owner to `grantPermissionsUrl` to approve the missing capabilities on the existing install, then read the store again. `grantPermissionsUrl` is `null` when nothing is missing, on stores connected with a custom-app token, and always on WooCommerce. The capability-to-scope table is on the [Shopify](https://pozy.ai/docs/platforms/shopify#permissions) page. ## Shared conventions | Topic | Rule | | --- | --- | | Ids | The platform's own ids, as strings. Read them from responses, never build them | | Money | Responses return `{ "amount": "24.00", "currency": "USD" }`. Write bodies take a plain decimal in the store currency, as a number or string | | Product status | `active`, `draft`, `pending_review`, `rejected`, `inactive`, `archived` or `deleted`. The platform's raw value is in `platformStatus` | | Timestamps | ISO 8601, UTC | | Pagination | Lists return `{ , nextCursor }`. Pass `limit` and the previous `nextCursor` as `cursor`. `nextCursor` is `null` on the last page | | Storage | Pozy keeps no copy of store data. Each call goes to the store live, so results are always current | | Unsupported operations | `400 platform_not_supported`. Check `capabilities` first | ## Errors | Error | Cause | Fix | | --- | --- | --- | | `400 platform_not_supported` | The account is not a store, or its platform lacks the operation | Check `capabilities` on `GET /v1/commerce/store` | | `400 invalid_field_value` | The platform rejected a field, or it does not exist there | Read the message; see the WooCommerce differences on [WordPress](https://pozy.ai/docs/platforms/wordpress#woocommerce) | | `403 insufficient_permissions` | The store has not granted the scope | Send the owner to `grantPermissionsUrl`, or reconnect | | `404 product_not_found`, `404 resource_not_found` | Deleted, or another store's id | List the resource for a current id | | `409 catalog_sync_conflict` | A sync for that store and catalog exists, or a run is in progress | Run the existing sync, or wait | | `429` | Your rate limit or the platform's | Wait for `Retry-After` | | `502` | The platform failed or was unavailable | Check the result of the earlier write before retrying | ## In this section - [Products](https://pozy.ai/docs/commerce/products): Catalog, variants, images, stock, collections, discounts and Meta catalog sync. - [Orders](https://pozy.ai/docs/commerce/orders): Read orders in one shape across stores, with attribution and webhooks. - [Blogs](https://pozy.ai/docs/commerce/blogs): One request to publish or schedule an article on four platforms. - [Site pixels](https://pozy.ai/docs/commerce/site-pixels): Put Meta, TikTok, Google and other tags on a store or site in one call. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/commerce/store`](https://pozy.ai/docs/api/getCommerceStore) | Get a store | | [`GET /v1/commerce/orders`](https://pozy.ai/docs/api/listCommerceOrders) | List orders | | [`GET /v1/commerce/orders/{orderId}`](https://pozy.ai/docs/api/getCommerceOrder) | Get an order | | [`GET /v1/commerce/products`](https://pozy.ai/docs/api/listCommerceProducts) | List products | | [`POST /v1/commerce/products`](https://pozy.ai/docs/api/createCommerceProduct) | Create a product | | [`POST /v1/commerce/products/state`](https://pozy.ai/docs/api/changeCommerceProductState) | Activate, deactivate, archive or delete products | | [`GET /v1/commerce/products/{productId}`](https://pozy.ai/docs/api/getCommerceProduct) | Get a product | | [`PATCH /v1/commerce/products/{productId}`](https://pozy.ai/docs/api/updateCommerceProduct) | Update a product | | [`POST /v1/commerce/products/{productId}/price`](https://pozy.ai/docs/api/updateCommerceProductPrices) | Update variant prices | | [`GET /v1/commerce/collections`](https://pozy.ai/docs/api/listCommerceCollections) | List collections | | [`POST /v1/commerce/collections`](https://pozy.ai/docs/api/createCommerceCollection) | Create a collection | | [`GET /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/getCommerceCollection) | Get a collection | | [`PATCH /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/updateCommerceCollection) | Update a collection | | [`DELETE /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/deleteCommerceCollection) | Delete a collection | | [`POST /v1/commerce/collections/{collectionId}/products`](https://pozy.ai/docs/api/changeCommerceCollectionProducts) | Add or remove products in a collection | | [`POST /v1/commerce/products/{productId}/variants`](https://pozy.ai/docs/api/createCommerceProductVariants) | Add variants | | [`DELETE /v1/commerce/products/{productId}/variants`](https://pozy.ai/docs/api/deleteCommerceProductVariants) | Delete variants | | [`POST /v1/commerce/products/{productId}/options`](https://pozy.ai/docs/api/createCommerceProductOptions) | Add options | | [`DELETE /v1/commerce/products/{productId}/options`](https://pozy.ai/docs/api/deleteCommerceProductOptions) | Delete options | | [`POST /v1/commerce/products/{productId}/images`](https://pozy.ai/docs/api/addCommerceProductImages) | Add images | | [`DELETE /v1/commerce/products/{productId}/images`](https://pozy.ai/docs/api/removeCommerceProductImages) | Remove images | | [`POST /v1/commerce/products/{productId}/images/reorder`](https://pozy.ai/docs/api/reorderCommerceProductImages) | Reorder images | | [`POST /v1/commerce/products/{productId}/duplicate`](https://pozy.ai/docs/api/duplicateCommerceProduct) | Duplicate a product | | [`GET /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/listCommerceProductMetafields) | List product metafields | | [`PUT /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/setCommerceProductMetafields) | Set product metafields | | [`DELETE /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/deleteCommerceProductMetafields) | Delete product metafields | | [`POST /v1/commerce/products/{productId}/channels`](https://pozy.ai/docs/api/changeCommerceProductChannels) | Publish or unpublish a product | | [`GET /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/listCommerceCollectionMetafields) | List collection metafields | | [`PUT /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/setCommerceCollectionMetafields) | Set collection metafields | | [`DELETE /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/deleteCommerceCollectionMetafields) | Delete collection metafields | | [`POST /v1/commerce/collections/{collectionId}/channels`](https://pozy.ai/docs/api/changeCommerceCollectionChannels) | Publish or unpublish a collection | | [`POST /v1/commerce/products/tags`](https://pozy.ai/docs/api/changeCommerceProductTags) | Add or remove tags in bulk | | [`POST /v1/commerce/collections/{collectionId}/reorder`](https://pozy.ai/docs/api/reorderCommerceCollectionProducts) | Reorder products in a collection | | [`GET /v1/commerce/pages`](https://pozy.ai/docs/api/listCommercePages) | List pages | | [`POST /v1/commerce/pages`](https://pozy.ai/docs/api/createCommercePage) | Create a page | | [`GET /v1/commerce/pages/{pageId}`](https://pozy.ai/docs/api/getCommercePage) | Get a page | | [`PATCH /v1/commerce/pages/{pageId}`](https://pozy.ai/docs/api/updateCommercePage) | Update a page | | [`DELETE /v1/commerce/pages/{pageId}`](https://pozy.ai/docs/api/deleteCommercePage) | Delete a page | | [`GET /v1/commerce/locations`](https://pozy.ai/docs/api/listCommerceLocations) | List locations | | [`GET /v1/commerce/inventory`](https://pozy.ai/docs/api/listCommerceInventory) | Get a product's stock | | [`POST /v1/commerce/products/{productId}/inventory`](https://pozy.ai/docs/api/changeCommerceInventory) | Set or adjust stock | | [`GET /v1/commerce/channels`](https://pozy.ai/docs/api/listCommerceChannels) | List sales channels | | [`GET /v1/commerce/discounts`](https://pozy.ai/docs/api/listCommerceDiscounts) | List discounts | | [`POST /v1/commerce/discounts`](https://pozy.ai/docs/api/createCommerceDiscount) | Create a discount | | [`GET /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/getCommerceDiscount) | Get a discount | | [`PATCH /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/updateCommerceDiscount) | Update a discount | | [`DELETE /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/deleteCommerceDiscount) | Delete a discount | | [`POST /v1/commerce/discounts/{discountId}/state`](https://pozy.ai/docs/api/setCommerceDiscountActive) | Activate or deactivate a discount | | [`POST /v1/commerce/discounts/{discountId}/codes`](https://pozy.ai/docs/api/addCommerceDiscountCodes) | Add codes to a discount | | [`GET /v1/commerce/redirects`](https://pozy.ai/docs/api/listCommerceRedirects) | List URL redirects | | [`POST /v1/commerce/redirects`](https://pozy.ai/docs/api/createCommerceRedirect) | Create a URL redirect | | [`PATCH /v1/commerce/redirects/{redirectId}`](https://pozy.ai/docs/api/updateCommerceRedirect) | Update a URL redirect | | [`DELETE /v1/commerce/redirects/{redirectId}`](https://pozy.ai/docs/api/deleteCommerceRedirect) | Delete a URL redirect | | [`GET /v1/commerce/menus`](https://pozy.ai/docs/api/listCommerceMenus) | List navigation menus | | [`POST /v1/commerce/menus`](https://pozy.ai/docs/api/createCommerceMenu) | Create a navigation menu | | [`GET /v1/commerce/menus/{menuId}`](https://pozy.ai/docs/api/getCommerceMenu) | Get a navigation menu | | [`PUT /v1/commerce/menus/{menuId}`](https://pozy.ai/docs/api/updateCommerceMenu) | Replace a navigation menu | | [`DELETE /v1/commerce/menus/{menuId}`](https://pozy.ai/docs/api/deleteCommerceMenu) | Delete a navigation menu | | [`GET /v1/commerce/metaobject-definitions`](https://pozy.ai/docs/api/listCommerceMetaobjectDefinitions) | List metaobject definitions | | [`GET /v1/commerce/metaobjects`](https://pozy.ai/docs/api/listCommerceMetaobjects) | List metaobjects of a type | | [`POST /v1/commerce/metaobjects`](https://pozy.ai/docs/api/createCommerceMetaobject) | Create a metaobject | | [`GET /v1/commerce/metaobjects/{metaobjectId}`](https://pozy.ai/docs/api/getCommerceMetaobject) | Get a metaobject | | [`PATCH /v1/commerce/metaobjects/{metaobjectId}`](https://pozy.ai/docs/api/updateCommerceMetaobject) | Update a metaobject | | [`DELETE /v1/commerce/metaobjects/{metaobjectId}`](https://pozy.ai/docs/api/deleteCommerceMetaobject) | Delete a metaobject | | [`GET /v1/commerce/markets`](https://pozy.ai/docs/api/listCommerceMarkets) | List markets | | [`GET /v1/commerce/price-lists`](https://pozy.ai/docs/api/listCommercePriceLists) | List price lists | | [`PUT /v1/commerce/price-lists/{priceListId}/prices`](https://pozy.ai/docs/api/setCommercePriceListPrices) | Set fixed prices | | [`DELETE /v1/commerce/price-lists/{priceListId}/prices`](https://pozy.ai/docs/api/deleteCommercePriceListPrices) | Remove fixed prices | | [`PUT /v1/commerce/marketing-activities`](https://pozy.ai/docs/api/upsertCommerceMarketingActivity) | Record a marketing activity | | [`DELETE /v1/commerce/marketing-activities/{remoteId}`](https://pozy.ai/docs/api/deleteCommerceMarketingActivity) | Delete a marketing activity | | [`POST /v1/commerce/marketing-activities/{remoteId}/engagements`](https://pozy.ai/docs/api/addCommerceMarketingEngagement) | Report daily engagement | | [`GET /v1/commerce/catalog-syncs`](https://pozy.ai/docs/api/listCommerceCatalogSyncs) | List catalog syncs | | [`POST /v1/commerce/catalog-syncs`](https://pozy.ai/docs/api/createCommerceCatalogSync) | Sync a store into a Meta catalog | | [`GET /v1/commerce/catalog-syncs/{syncId}`](https://pozy.ai/docs/api/getCommerceCatalogSync) | Get a catalog sync | | [`DELETE /v1/commerce/catalog-syncs/{syncId}`](https://pozy.ai/docs/api/deleteCommerceCatalogSync) | Stop a catalog sync | | [`POST /v1/commerce/catalog-syncs/{syncId}/run`](https://pozy.ai/docs/api/runCommerceCatalogSync) | Run a catalog sync now | ## Webhook events | Event | When it fires | | --- | --- | | [`commerce.product.created`](https://pozy.ai/docs/webhooks/events#commerce-product-created) | Commerce product created. | | [`commerce.product.updated`](https://pozy.ai/docs/webhooks/events#commerce-product-updated) | Commerce product updated. | | [`commerce.product.deleted`](https://pozy.ai/docs/webhooks/events#commerce-product-deleted) | Commerce product deleted. | | [`commerce.order.created`](https://pozy.ai/docs/webhooks/events#commerce-order-created) | Commerce order created. | | [`commerce.order.updated`](https://pozy.ai/docs/webhooks/events#commerce-order-updated) | Commerce order updated. | | [`commerce.order.refunded`](https://pozy.ai/docs/webhooks/events#commerce-order-refunded) | Commerce order refunded. | --- # Products and catalog List, create and edit products, variants, images and stock on Shopify and WooCommerce, organise collections and discounts, and sync a store into a Meta catalog. The Commerce API manages a connected store's catalog in one platform-neutral shape. The same calls create a product on Shopify or WooCommerce, change its stock, put it in a collection, discount it and keep a Meta catalog in step for catalog ads. Name the store with `accountId` on every call. ## List products `query` goes to the platform's own product search unchanged (on Shopify, for example, `vendor:Acme tag:summer`). `collectionId` narrows the list to one collection. Pages hold up to 100 products, 20 by default. ```bash cURL curl "https://api.pozy.ai/v1/commerce/products?accountId=6710c4b2e4b0d2f1a8c95e3a&status=active&limit=50" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node let cursor: string | undefined; do { const query = new URLSearchParams({ accountId: "6710c4b2e4b0d2f1a8c95e3a", status: "active", limit: "50" }); if (cursor) query.set("cursor", cursor); const res = await fetch(`https://api.pozy.ai/v1/commerce/products?${query}`, { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const p of data.products) console.log(p.id, p.title, p.variants[0]?.price.amount); cursor = data.nextCursor ?? undefined; } while (cursor); ``` ```python Python import os, requests params = {"accountId": "6710c4b2e4b0d2f1a8c95e3a", "status": "active", "limit": 50} while True: page = requests.get( "https://api.pozy.ai/v1/commerce/products", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params=params, ).json() for p in page["products"]: print(p["id"], p["title"]) if not page["nextCursor"]: break params["cursor"] = page["nextCursor"] ``` Each product carries its `variants` (with `price`, `compareAtPrice`, `sku`, `inventoryQuantity` and `options`), `images`, `featuredImage`, `options`, `tags`, `seo`, the public storefront `url` and `totalInventory`. A `status` the platform has no equivalent for returns an empty page rather than an error. ## Create a product A new product starts as `draft` unless you send `status: "active"`. It takes up to 3 options, 100 variants and 20 images. Image URLs must be public: the store downloads them. ```bash cURL curl -X POST https://api.pozy.ai/v1/commerce/products \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "title": "Merino beanie", "descriptionHtml": "

Soft merino wool, one size.

", "status": "active", "tags": ["winter", "accessories"], "images": [{ "url": "https://cdn.example.com/beanie-grey.jpg", "altText": "Grey merino beanie" }], "options": [{ "name": "Colour", "values": ["Grey", "Navy"] }], "variants": [ { "sku": "BEANIE-GRY", "price": "29.00", "options": [{ "name": "Colour", "value": "Grey" }] }, { "sku": "BEANIE-NVY", "price": "29.00", "options": [{ "name": "Colour", "value": "Navy" }] } ] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/commerce/products", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6710c4b2e4b0d2f1a8c95e3a", title: "Merino beanie", descriptionHtml: "

Soft merino wool, one size.

", status: "active", images: [{ url: "https://cdn.example.com/beanie-grey.jpg", altText: "Grey merino beanie" }], options: [{ name: "Colour", values: ["Grey", "Navy"] }], variants: [ { sku: "BEANIE-GRY", price: "29.00", options: [{ name: "Colour", value: "Grey" }] }, { sku: "BEANIE-NVY", price: "29.00", options: [{ name: "Colour", value: "Navy" }] }, ], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.product.id); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/commerce/products", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "title": "Merino beanie", "status": "active", "options": [{"name": "Colour", "values": ["Grey", "Navy"]}], "variants": [ {"sku": "BEANIE-GRY", "price": "29.00", "options": [{"name": "Colour", "value": "Grey"}]}, {"sku": "BEANIE-NVY", "price": "29.00", "options": [{"name": "Colour", "value": "Navy"}]}, ], }, ) product_id = resp.json()["product"]["id"] ``` ## Edit products | Change | Endpoint | Notes | | --- | --- | --- | | Title, description, handle, tags, SEO, vendor, type | `PATCH /v1/commerce/products/{productId}` | `tags` replaces the whole list | | Variant prices and compare-at prices | `POST /v1/commerce/products/{productId}/price` | Send `compareAtPrice: null` to end a sale | | Add or delete variants and options | `.../variants`, `.../options` | | | Add, remove or reorder images | `.../images`, `.../images/reorder` | Removal on Shopify needs `products.images_remove` | | Copy a product | `POST .../duplicate` | New `title`, starts as draft, `includeImages` defaults to `true` | | Tags on many products | `POST /v1/commerce/products/tags` | `add` and `remove` lists, per-product results | | Activate, deactivate, archive or delete | `POST /v1/commerce/products/state` | Up to 50 ids, per-product results | | Custom fields | `PUT`, `GET`, `DELETE .../metafields` | Metafields on Shopify, custom fields on WooCommerce | | Sales channels (Shopify) | `POST .../channels` | `publish` and `unpublish` lists of channel ids | Bulk calls report each outcome in `succeeded` and `failed`, so one bad id does not stop the rest: ```json { "action": "archive", "succeeded": ["8214569812345"], "failed": [{ "productId": "8214569899999", "message": "Product not found" }] } ``` > **Deletes are permanent:** > > `action: "delete"` removes the product on the platform and cannot be undone. Archive it if you might need it again. ## Set stock Read location ids with `GET /v1/commerce/locations` (WooCommerce has one, `default`), then change variants. `mode: "set"` makes `quantity` the new available count. `mode: "adjust"` adds it, so send a negative number to subtract. ```bash cURL curl -X POST https://api.pozy.ai/v1/commerce/products/8214569812345/inventory \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "mode": "adjust", "changes": [{ "variantId": "45123456789012", "locationId": "71234567890", "quantity": -3 }] }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/commerce/products/8214569812345/inventory", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6710c4b2e4b0d2f1a8c95e3a", mode: "adjust", changes: [{ variantId: "45123456789012", locationId: "71234567890", quantity: -3 }], }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.inventory[0].levels); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/commerce/products/8214569812345/inventory", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "mode": "adjust", "changes": [{"variantId": "45123456789012", "locationId": "71234567890", "quantity": -3}], }, ) ``` The response is the product's stock after the change, per location: `available`, `onHand`, `committed` and `incoming`. Read it any time with `GET /v1/commerce/inventory?accountId=...&productId=...`. On WooCommerce an adjust reads then writes, so a sale that lands in between can be overwritten. ## Collections and discounts Collections group products (Shopify collections, WooCommerce categories). Create one with a list of `productIds`, then add or remove products with `POST /v1/commerce/collections/{collectionId}/products` and `add` / `remove` lists. `sortOrder` and SEO fields are Shopify-only. Discounts take a `method` (`code` or `automatic`) and a `type` (`percentage`, `fixed_amount` or `free_shipping`): ```bash cURL curl -X POST https://api.pozy.ai/v1/commerce/discounts \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "title": "Instagram followers 15%", "method": "code", "type": "percentage", "code": "INSTA15", "percentage": 15, "oncePerCustomer": true, "endsAt": "2026-12-31T23:59:59Z" }' ``` Pair a code with a post: create the discount, then publish the code in a caption. WooCommerce supports code discounts (coupons) only, cannot start one in the future and cannot require a minimum quantity. Activate or pause a discount with `POST /v1/commerce/discounts/{discountId}/state`. ## Sync a store into a Meta catalog A catalog sync keeps a Meta product catalog in step with the store, for catalog ads and shops. You need: - `accountId`: the store. - `catalogAccountId`: a connected `facebook`, `instagram` or `metaads` account whose Meta login can manage catalogs. WhatsApp accounts do not work here. - `catalogId`: the Meta catalog id, from `GET /v1/ads/catalogs`. ```bash cURL curl -X POST https://api.pozy.ai/v1/commerce/catalog-syncs \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accountId": "6710c4b2e4b0d2f1a8c95e3a", "catalogAccountId": "6710c52de4b0d2f1a8c95e95", "catalogId": "1188234455667788" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/commerce/catalog-syncs", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ accountId: "6710c4b2e4b0d2f1a8c95e3a", catalogAccountId: "6710c52de4b0d2f1a8c95e95", catalogId: "1188234455667788" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.catalogSync.id, data.catalogSync.runStatus); // "pending" ``` ```python Python sync = requests.post( "https://api.pozy.ai/v1/commerce/catalog-syncs", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"accountId": "6710c4b2e4b0d2f1a8c95e3a", "catalogAccountId": "6710c52de4b0d2f1a8c95e95", "catalogId": "1188234455667788"}, ).json()["catalogSync"] ``` The call returns `202` and starts the first full run. Poll `GET /v1/commerce/catalog-syncs/{syncId}`: | Field | Meaning | | --- | --- | | `runStatus` | `pending`, `running`, then `succeeded` or `failed` | | `itemsSent` | Variants Meta accepted in the last run | | `itemsSkipped` | Active products left out because they are unpublished or have no image | | `itemsDeleted` | This store's items removed because they are gone or no longer listable | | `lastError` | Why the last run failed, or a note on rejected items | How a sync behaves: - One catalog item per variant, grouped by product, with ids namespaced to the store. A run only touches that store's items, so one catalog can hold several stores. - A product is listable when it is `active`, published to the online store and has at least one image. - Product changes refresh their items within minutes. Every sync also runs once a day if it has not run in the last 20 hours. A run still going after 30 minutes counts as failed and is retried on the next daily pass. - Run now with `POST .../run` (`409 catalog_sync_conflict` while a run is in progress). Stop with `DELETE`: items already in the catalog stay. - One sync per store and catalog pair. Syncs of a disconnected store are skipped until it reconnects. Then build catalog ads on top: see [Catalogs](https://pozy.ai/docs/ads/catalogs). ## Use products in posts Products give your posts real data. A common pattern: list active products, pick the newest, and create a post with its first image and storefront `url`, tagged with UTM parameters. On Shopify, record the post as a marketing activity so the merchant sees it in their Marketing section (see [Shopify](https://pozy.ai/docs/platforms/shopify#shopify-only-operations)). ## Webhook events Pozy subscribes to the store's product changes when it connects. The events carry the store and the product id; fetch the product with `GET /v1/commerce/products/{productId}` for its current state. | Event | When it fires | | --- | --- | | [`commerce.product.created`](https://pozy.ai/docs/webhooks/events#commerce-product-created) | Commerce product created. | | [`commerce.product.updated`](https://pozy.ai/docs/webhooks/events#commerce-product-updated) | Commerce product updated. | | [`commerce.product.deleted`](https://pozy.ai/docs/webhooks/events#commerce-product-deleted) | Commerce product deleted. | ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/commerce/products`](https://pozy.ai/docs/api/listCommerceProducts) | List products | | [`POST /v1/commerce/products`](https://pozy.ai/docs/api/createCommerceProduct) | Create a product | | [`POST /v1/commerce/products/state`](https://pozy.ai/docs/api/changeCommerceProductState) | Activate, deactivate, archive or delete products | | [`GET /v1/commerce/products/{productId}`](https://pozy.ai/docs/api/getCommerceProduct) | Get a product | | [`PATCH /v1/commerce/products/{productId}`](https://pozy.ai/docs/api/updateCommerceProduct) | Update a product | | [`POST /v1/commerce/products/{productId}/price`](https://pozy.ai/docs/api/updateCommerceProductPrices) | Update variant prices | | [`GET /v1/commerce/collections`](https://pozy.ai/docs/api/listCommerceCollections) | List collections | | [`POST /v1/commerce/collections`](https://pozy.ai/docs/api/createCommerceCollection) | Create a collection | | [`GET /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/getCommerceCollection) | Get a collection | | [`PATCH /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/updateCommerceCollection) | Update a collection | | [`DELETE /v1/commerce/collections/{collectionId}`](https://pozy.ai/docs/api/deleteCommerceCollection) | Delete a collection | | [`POST /v1/commerce/collections/{collectionId}/products`](https://pozy.ai/docs/api/changeCommerceCollectionProducts) | Add or remove products in a collection | | [`POST /v1/commerce/products/{productId}/variants`](https://pozy.ai/docs/api/createCommerceProductVariants) | Add variants | | [`DELETE /v1/commerce/products/{productId}/variants`](https://pozy.ai/docs/api/deleteCommerceProductVariants) | Delete variants | | [`POST /v1/commerce/products/{productId}/options`](https://pozy.ai/docs/api/createCommerceProductOptions) | Add options | | [`DELETE /v1/commerce/products/{productId}/options`](https://pozy.ai/docs/api/deleteCommerceProductOptions) | Delete options | | [`POST /v1/commerce/products/{productId}/images`](https://pozy.ai/docs/api/addCommerceProductImages) | Add images | | [`DELETE /v1/commerce/products/{productId}/images`](https://pozy.ai/docs/api/removeCommerceProductImages) | Remove images | | [`POST /v1/commerce/products/{productId}/images/reorder`](https://pozy.ai/docs/api/reorderCommerceProductImages) | Reorder images | | [`POST /v1/commerce/products/{productId}/duplicate`](https://pozy.ai/docs/api/duplicateCommerceProduct) | Duplicate a product | | [`GET /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/listCommerceProductMetafields) | List product metafields | | [`PUT /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/setCommerceProductMetafields) | Set product metafields | | [`DELETE /v1/commerce/products/{productId}/metafields`](https://pozy.ai/docs/api/deleteCommerceProductMetafields) | Delete product metafields | | [`POST /v1/commerce/products/{productId}/channels`](https://pozy.ai/docs/api/changeCommerceProductChannels) | Publish or unpublish a product | | [`GET /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/listCommerceCollectionMetafields) | List collection metafields | | [`PUT /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/setCommerceCollectionMetafields) | Set collection metafields | | [`DELETE /v1/commerce/collections/{collectionId}/metafields`](https://pozy.ai/docs/api/deleteCommerceCollectionMetafields) | Delete collection metafields | | [`POST /v1/commerce/collections/{collectionId}/channels`](https://pozy.ai/docs/api/changeCommerceCollectionChannels) | Publish or unpublish a collection | | [`POST /v1/commerce/products/tags`](https://pozy.ai/docs/api/changeCommerceProductTags) | Add or remove tags in bulk | | [`POST /v1/commerce/collections/{collectionId}/reorder`](https://pozy.ai/docs/api/reorderCommerceCollectionProducts) | Reorder products in a collection | | [`GET /v1/commerce/locations`](https://pozy.ai/docs/api/listCommerceLocations) | List locations | | [`GET /v1/commerce/inventory`](https://pozy.ai/docs/api/listCommerceInventory) | Get a product's stock | | [`POST /v1/commerce/products/{productId}/inventory`](https://pozy.ai/docs/api/changeCommerceInventory) | Set or adjust stock | | [`GET /v1/commerce/channels`](https://pozy.ai/docs/api/listCommerceChannels) | List sales channels | | [`GET /v1/commerce/discounts`](https://pozy.ai/docs/api/listCommerceDiscounts) | List discounts | | [`POST /v1/commerce/discounts`](https://pozy.ai/docs/api/createCommerceDiscount) | Create a discount | | [`GET /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/getCommerceDiscount) | Get a discount | | [`PATCH /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/updateCommerceDiscount) | Update a discount | | [`DELETE /v1/commerce/discounts/{discountId}`](https://pozy.ai/docs/api/deleteCommerceDiscount) | Delete a discount | | [`POST /v1/commerce/discounts/{discountId}/state`](https://pozy.ai/docs/api/setCommerceDiscountActive) | Activate or deactivate a discount | | [`POST /v1/commerce/discounts/{discountId}/codes`](https://pozy.ai/docs/api/addCommerceDiscountCodes) | Add codes to a discount | | [`GET /v1/commerce/catalog-syncs`](https://pozy.ai/docs/api/listCommerceCatalogSyncs) | List catalog syncs | | [`POST /v1/commerce/catalog-syncs`](https://pozy.ai/docs/api/createCommerceCatalogSync) | Sync a store into a Meta catalog | | [`GET /v1/commerce/catalog-syncs/{syncId}`](https://pozy.ai/docs/api/getCommerceCatalogSync) | Get a catalog sync | | [`DELETE /v1/commerce/catalog-syncs/{syncId}`](https://pozy.ai/docs/api/deleteCommerceCatalogSync) | Stop a catalog sync | | [`POST /v1/commerce/catalog-syncs/{syncId}/run`](https://pozy.ai/docs/api/runCommerceCatalogSync) | Run a catalog sync now | The older per-account product endpoints (`/v1/accounts/{accountId}/products`) still work but are deprecated. Use the Commerce API for new code. --- # Orders Read Shopify and WooCommerce orders in one shape, with line items, tax, refunds and attribution, and keep them in sync with webhooks instead of polling. Orders from Shopify and WooCommerce come back in the same shape: line items with unit prices, tax per line, discounts, shipping and fee lines, billing details, payment status, refunds and where the buyer came from. That is enough to rebuild an invoice, feed an analytics tool or attribute a sale to a post without knowing which store it came from. Orders are read-only. ## Before you start | Store | What it needs | | --- | --- | | Shopify | The `orders.read` capability, which needs the optional scopes `read_orders` and `read_all_orders`. Without `read_all_orders`, Shopify returns only the last 60 days. Check with `GET /v1/commerce/store` and send the owner to `grantPermissionsUrl` | | WooCommerce | Nothing extra: any user who can manage WooCommerce can read orders | Shopify also gates buyer details behind its protected customer data approval. Where the approval does not cover them, `customer`, `billingAddress` and `shippingAddress` are `null` and the rest of the order is complete. ## List orders Lists come newest first. Filter by `createdAfter` and `createdBefore`, or pass `updatedAfter` to page forward from the last change you saw: results then come oldest first. Shopify pages hold up to 25 orders, WooCommerce pages up to 100. ```bash cURL curl "https://api.pozy.ai/v1/commerce/orders?accountId=6710c4b2e4b0d2f1a8c95e3a&updatedAfter=2026-10-29T00:00:00Z&limit=25" \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/commerce/orders?accountId=6710c4b2e4b0d2f1a8c95e3a&updatedAfter=2026-10-29T00:00:00Z&limit=25", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); for (const o of data.orders) console.log(o.number, o.status, o.total.amount, o.total.currency); ``` ```python Python import os, requests orders = requests.get( "https://api.pozy.ai/v1/commerce/orders", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, params={"accountId": "6710c4b2e4b0d2f1a8c95e3a", "updatedAfter": "2026-10-29T00:00:00Z", "limit": 25}, ).json()["orders"] ``` ```json { "id": "6148123456", "accountId": "6710c4b2e4b0d2f1a8c95e3a", "platform": "shopify", "number": "#1042", "status": "awaiting_fulfillment", "platformStatus": "unfulfilled", "paymentStatus": "paid", "currency": "EUR", "taxesIncluded": true, "subtotal": { "amount": "58.00", "currency": "EUR" }, "discountTotal": { "amount": "8.70", "currency": "EUR" }, "shippingTotal": { "amount": "4.90", "currency": "EUR" }, "taxTotal": { "amount": "9.40", "currency": "EUR" }, "total": { "amount": "54.20", "currency": "EUR" }, "discountCodes": ["INSTA15"], "lineItems": [ { "id": "15011223344", "productId": "8214569812345", "variantId": "45123456789012", "sku": "BEANIE-GRY", "title": "Merino beanie", "quantity": 2, "unitPrice": { "amount": "29.00", "currency": "EUR" }, "total": { "amount": "49.30", "currency": "EUR" } } ], "refunds": [], "createdAt": "2026-10-29T14:02:11Z", "updatedAt": "2026-10-29T14:02:15Z" } ``` Get one order with `GET /v1/commerce/orders/{orderId}?accountId=...`. ## Read the fields | Field | Meaning | | --- | --- | | `status` | `pending_payment`, `on_hold`, `awaiting_fulfillment`, `partially_fulfilled`, `in_transit`, `delivered`, `completed` or `cancelled`. Raw value in `platformStatus` | | `paymentStatus` | `pending`, `authorized`, `partially_paid`, `paid`, `partially_refunded`, `refunded`, `voided` or `failed` | | `subtotal`, `unitPrice` | Before discounts | | `total` | After discounts. `taxesIncluded` says whether amounts include tax | | `lineItems[].taxLines`, `shippingLines`, `feeLines`, `taxLines` | Tax and charges per line, each with `title`, `ratePercent` and `amount` | | `refunds` | Each refund with `amount`, `note` and the refunded line items. `refundedTotal` sums them | | `fulfillments` | Carrier, tracking number and tracking URL | | `customer`, `billingAddress`, `shippingAddress` | Buyer details, or `null` where the store does not share them | | `buyerNote`, `discountCodes`, `paymentMethods` | As entered at checkout | All amounts are in the store currency as decimal strings. ## Attribution `attribution` says where the order came from. Use it to credit a sale to a post, a campaign or a channel. ```json "attribution": { "ready": true, "firstVisit": null, "lastVisit": { "occurredAt": "2026-10-29T13:55:00Z", "landingUrl": "https://acme.com/products/merino-beanie?utm_source=instagram&utm_campaign=winter", "referrerUrl": "https://l.instagram.com/", "source": "instagram", "sourceType": "SOCIAL", "utm": { "source": "instagram", "medium": "social", "campaign": "winter", "term": null, "content": null } }, "customAttributes": [{ "key": "gift_message", "value": "Happy birthday" }] } ``` | Store | What it records | | --- | --- | | Shopify | First and last visit from the customer journey. Shopify attributes a new order shortly after it is placed: until then `ready` is `false` and both visits are `null`. No event fires when it becomes ready, so read the order again later | | WooCommerce | The last visit only (`firstVisit` and `occurredAt` are always `null`). Needs WooCommerce 8.5 or later with Order Attribution turned on, for orders placed after that | `customAttributes` holds cart and note attributes on Shopify and public order meta on WooCommerce. > **Tag your links:** > > Attribution is only as good as your UTM parameters. Put `utm_source`, `utm_medium` and `utm_campaign` on every link you publish, and you can group orders by post or campaign straight from `lastVisit.utm`. ## Keep in sync with webhooks Polling works, but webhooks are cheaper. When a store grants order access, Pozy subscribes to its order changes (on WooCommerce, when the site is connected). Subscribe your [webhook endpoint](https://pozy.ai/docs/webhooks) to the three events below. They carry the store and the order id; fetch the full order by id. ```json { "id": "evt_6f1c2a9b8d7e4c3a", "event": "commerce.order.created", "timestamp": "2026-10-29T14:02:16.000Z", "store": { "accountId": "6710c4b2e4b0d2f1a8c95e3a", "platform": "shopify", "shopId": "64822345678" }, "resource": { "type": "order", "id": "6148123456", "status": "awaiting_fulfillment", "platformStatus": "unfulfilled" } } ``` A refund usually fires `commerce.order.updated` as well as `commerce.order.refunded`. A WooCommerce site connected before order webhooks were available gets them the next time it reconnects or a catalog sync is created. | Event | When it fires | | --- | --- | | [`commerce.order.created`](https://pozy.ai/docs/webhooks/events#commerce-order-created) | Commerce order created. | | [`commerce.order.updated`](https://pozy.ai/docs/webhooks/events#commerce-order-updated) | Commerce order updated. | | [`commerce.order.refunded`](https://pozy.ai/docs/webhooks/events#commerce-order-refunded) | Commerce order refunded. | ## Sync without gaps To backfill or recover after downtime, page forward with `updatedAfter`: 1. Store the newest `updatedAt` you have processed. 2. Start each sync a few minutes before it, because an order edited while you page can shift the list. 3. Drop duplicates by `id`. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/commerce/orders`](https://pozy.ai/docs/api/listCommerceOrders) | List orders | | [`GET /v1/commerce/orders/{orderId}`](https://pozy.ai/docs/api/getCommerceOrder) | Get an order | | [`GET /v1/commerce/store`](https://pozy.ai/docs/api/getCommerceStore) | Get a store | --- # Publish blog articles Create, schedule, update and delete blog articles on Shopify, WordPress, Wix and Webflow with one request shape. The Blogs API publishes long-form articles to the sites your users already run. One request shape works on Shopify blogs, WordPress posts, Wix Blog and Webflow CMS collections. Scheduling is native: the site itself publishes at the time you set, so an article goes out even if your servers are down. Blogs are separate from [social posts](https://pozy.ai/docs/publishing): a blog account is never a target of `POST /v1/posts`. ## How blogs map on each platform | | Shopify | WordPress | Wix | Webflow | | --- | --- | --- | --- | --- | | A "blog" is | One of the store's blogs ("News" by default) | The site (one blog per account) | The site (blog id `"1"`) | A CMS collection with a rich text field | | Blog id | Shopify's numeric id | `"1"` self-hosted, site id on WordPress.com | `"1"` | The collection id | | Create or delete blogs | Yes | No (`405`) | No (`405`) | No (`405`) | | Native scheduling | Yes | Yes | Yes | No (`400`) | | Drafts | Yes | Yes (default) | Yes | Yes (default) | | `seo` | Yes | No (`400`) | Yes | No (`400`) | | Featured image | Yes | Yes, imported into the media library | Yes, imported into the Media Manager | Yes, an image field | | Extra collection fields | | | | `fieldData` | ## Find the blog Always list blogs first and take the id from the response: ```bash cURL curl https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs \ -H "Authorization: Bearer $POZY_API_KEY" ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs", { headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` }, }); const data = await res.json(); if (!res.ok) throw new Error(data.error); const blogId = data.blogs[0].id; ``` ```python Python import os, requests blogs = requests.get( "https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, ).json()["blogs"] blog_id = blogs[0]["id"] ``` ```json { "platform": "shopify", "blogs": [{ "id": "121793282419", "platform": "shopify", "title": "News", "handle": "news" }], "nextCursor": null } ``` ## Create an article `title` is required. `bodyHtml` is raw HTML: Pozy does not convert Markdown, so render it first if that is what you store. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs/121793282419/articles \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: article-winter-guide-2026" \ -d '{ "title": "How to care for merino wool", "bodyHtml": "

Wash cold, dry flat, and skip the softener.

", "handle": "merino-care-guide", "excerpt": "Three rules that keep merino soft for years.", "tags": ["guides", "merino"], "author": "Maya Lindqvist", "image": { "url": "https://cdn.example.com/merino-care.jpg", "altText": "Folded merino jumpers" }, "seo": { "title": "Merino care guide", "description": "How to wash and dry merino wool." }, "publishDate": "2026-11-15T08:00:00Z" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs/121793282419/articles", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Idempotency-Key": "article-winter-guide-2026", "Content-Type": "application/json", }, body: JSON.stringify({ title: "How to care for merino wool", bodyHtml: "

Wash cold, dry flat, and skip the softener.

", tags: ["guides", "merino"], image: { url: "https://cdn.example.com/merino-care.jpg", altText: "Folded merino jumpers" }, publishDate: "2026-11-15T08:00:00Z", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.article.id, data.article.status); ``` ```python Python resp = requests.post( "https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs/121793282419/articles", headers={ "Authorization": f"Bearer {os.environ['POZY_API_KEY']}", "Idempotency-Key": "article-winter-guide-2026", }, json={ "title": "How to care for merino wool", "bodyHtml": "

Wash cold, dry flat, and skip the softener.

", "tags": ["guides", "merino"], "publishDate": "2026-11-15T08:00:00Z", }, ) article = resp.json()["article"] ``` The response returns `201` with the article as the site stored it, including the `handle` it really got and its `status`. ## Article fields | Field | Type | Notes | | --- | --- | --- | | `title` | string | Required on create | | `bodyHtml` | string | Raw HTML. Wix converts it to rich content; Webflow drops code blocks | | `handle` | string | URL slug. Generated from the title when left out. Shopify sets it once at creation, so set it yourself when the URL matters | | `excerpt` | string | Summary shown in listings | | `tags` | string[] | Matched by name and created when missing (WordPress up to 100, Wix up to 30). Replaces the full list on update | | `author` | string | Shopify: a display name. WordPress: the numeric user id. Wix: a site member id. Webflow: the referenced author item id | | `image` | `{ url, altText? }` | Featured image, downloaded by the site from a public URL. JPEG, PNG, WebP or GIF up to 10 MB on WordPress and Wix | | `seo` | `{ title?, description? }` | Shopify and Wix only | | `isPublished` | boolean | `false` keeps a draft and wins over any date. `true` publishes now | | `publishDate` | ISO 8601 | A future date schedules natively. Use an offset or `Z` | | `fieldData` | object | Webflow only: other collection fields by slug | ## Publish, draft or schedule | You want | Send | | --- | --- | | A draft | `isPublished: false` | | Live now | `isPublished: true` (no future `publishDate`) | | Scheduled | A future `publishDate`, and leave `isPublished` out | Scheduled articles read back with `isPublished: false` and `publishDate` set. WordPress reports `status: "future"`. On Webflow a future date returns `400` because Webflow's API cannot schedule; if you need a date there, hold the article in your own scheduler and publish it at the right time. > **`isPublished: false` beats the date:** > > Sending `isPublished: false` together with a future `publishDate` creates a draft, not a scheduled article. Omit `isPublished` when you schedule. ## Update and delete `PATCH .../articles/{articleId}` is a partial update: fields you leave out keep their value. - `tags` replaces the list. An empty array clears it. - An empty `bodyHtml` or `excerpt` clears it (WordPress). - `isPublished: false` moves a published or scheduled article back to draft (on Webflow, it unpublishes the item). - Leaving out `image` keeps the current featured image. There is no way to remove one. - Edits to a published article go live straight away. `DELETE .../articles/{articleId}` returns `204` and deletes the article on the site. It is permanent on every platform and Pozy keeps no copy. On Shopify, deleting a blog deletes every article in it. ## List and read articles `GET .../articles` returns articles a page at a time (`limit` 1 to 50, default 20, then `cursor` from `nextCursor`). WordPress includes `publish`, `future`, `draft`, `pending` and `private` posts the connected user can see. Wix lists return `bodyHtml: null`: get the single article for its body. Article ids are the site's own and come only from these responses. ## Gotchas - The site downloads images from your URL, so it must be publicly reachable without auth or redirect chains. - Use an `Idempotency-Key` on create. A timed-out request may have reached the site, and the key stops a retry from publishing twice. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency). - WordPress scheduling relies on the site's own cron. A site whose scheduled jobs do not run publishes after the set time, or never; Pozy does not force publication. - `url` is set only while an article is published. ## Endpoints | Endpoint | What it does | | --- | --- | | [`GET /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/listBlogs) | List blogs | | [`POST /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/createBlog) | Create a blog | | [`GET /v1/accounts/{accountId}/blogs/{blogId}`](https://pozy.ai/docs/api/getBlog) | Get a blog | | [`PATCH /v1/accounts/{accountId}/blogs/{blogId}`](https://pozy.ai/docs/api/updateBlog) | Update a blog | | [`DELETE /v1/accounts/{accountId}/blogs/{blogId}`](https://pozy.ai/docs/api/deleteBlog) | Delete a blog | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/listBlogArticles) | List blog articles | | [`POST /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/createBlogArticle) | Create a blog article | | [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/getBlogArticle) | Get a blog article | | [`PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/updateBlogArticle) | Update a blog article | | [`DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/deleteBlogArticle) | Delete a blog article | --- # Site pixels Install Meta, TikTok, Google, LinkedIn, Pinterest, X and ChatGPT tags on a connected Shopify, WordPress, Wix or Webflow site with one call, then check and remove them. Ad platforms measure conversions with a tag on the website. Pozy puts that tag on a connected store or site for you, with no theme edits and no copy-paste. You pick a tracking tag on a connected ads account, name the site, and Pozy installs it. On Shopify the tag also receives storefront events such as add to cart and purchase. On WordPress, Wix and Webflow it records page views. ## What you need | Piece | Where it comes from | | --- | --- | | An ads account | A connected Meta, TikTok, Google, LinkedIn, Pinterest, X or ChatGPT Ads account. See [Ads](https://pozy.ai/docs/ads) | | A tracking tag on it | `GET /v1/accounts/{accountId}/tracking-tags`, or create one. See [Pixels](https://pozy.ai/docs/ads/pixels) | | A site | A connected `shopify`, `wordpress`, `wix` or `webflow` account, passed as `storeAccountId` | ## Install a tag `accountId` in the path is the ads account. `tagId` is the tag's API id. ```bash cURL curl -X POST https://api.pozy.ai/v1/accounts/6710c537e4b0d2f1a8c95ea0/tracking-tags/1729525464415281/install \ -H "Authorization: Bearer $POZY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "storeAccountId": "6710c4b2e4b0d2f1a8c95e3a" }' ``` ```ts Node const res = await fetch("https://api.pozy.ai/v1/accounts/6710c537e4b0d2f1a8c95ea0/tracking-tags/1729525464415281/install", { method: "POST", headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ storeAccountId: "6710c4b2e4b0d2f1a8c95e3a" }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); console.log(data.install.installed, data.install.tags); ``` ```python Python import os, requests resp = requests.post( "https://api.pozy.ai/v1/accounts/6710c537e4b0d2f1a8c95ea0/tracking-tags/1729525464415281/install", headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}, json={"storeAccountId": "6710c4b2e4b0d2f1a8c95e3a"}, ) print(resp.json()["install"]) ``` ```json { "platform": "tiktokads", "install": { "storeAccountId": "6710c4b2e4b0d2f1a8c95e3a", "platform": "shopify", "shopDomain": "acme.myshopify.com", "tagPlatform": "tiktokads", "siteTagId": "D00IKHRC77UE0J0RTNHG", "installed": true, "installedTagId": "D00IKHRC77UE0J0RTNHG", "tags": [ { "platform": "metaads", "siteTagId": "1729525464415281" }, { "platform": "tiktokads", "siteTagId": "D00IKHRC77UE0J0RTNHG" } ], "replacedTagId": null } } ``` `siteTagId`, `installedTagId`, `replacedTagId` and `tags` carry the id the site fires (the TikTok pixel code, the Google `AW-` id, the X or ChatGPT pixel id), not the API `tagId`. ## Check and remove | Call | What it does | | --- | --- | | `GET .../tracking-tags/{tagId}/install?storeAccountId=...` | Whether this tag is the one the site fires, which tag of that platform it fires now (`installedTagId`), and every Pozy tag on the site (`tags`) | | `DELETE .../tracking-tags/{tagId}/install?storeAccountId=...` | Removes this tag and keeps other platforms' tags | Removing a tag that is not installed does nothing. On Shopify, Wix and Webflow, removing a tag while the site fires a different tag of the same platform changes nothing and returns `409 invalid_resource_state`: read the status and remove the tag named in `installedTagId`. ## How it works on each platform ### Shopify Shopify allows an app only one web pixel in each store. Pozy's pixel therefore holds all your tags, one per ad platform. Repeating an install changes nothing. Installing another tag for a platform that already has one swaps it in, and `replacedTagId` tells you which tag it replaced. The pixel listens to seven Shopify customer events and sends each to every installed platform: | Shopify event | Site event | Meta, TikTok, Pinterest, ChatGPT | Google, LinkedIn, X | | --- | --- | --- | --- | | `page_viewed` | `page_view` | Always | Always (Google only with a page-view conversion action) | | `product_viewed` | `view_content` | Always | LinkedIn and X when a matching conversion exists. Not sent to Google | | `product_added_to_cart` | `add_to_cart` | Always | When a matching conversion exists | | `search_submitted` | `search` | Always (ChatGPT as a custom `search` event, without the search text) | LinkedIn and X when a matching conversion exists. Not sent to Google | | `checkout_started` | `initiate_checkout` | Always | When a matching conversion exists | | `payment_info_submitted` | `add_payment_info` | Always (ChatGPT as a custom event) | LinkedIn and X when a matching conversion exists. Not sent to Google | | `checkout_completed` | `purchase` | Always | When a matching conversion exists | Product and cart events include the variant id, the value and the currency. Checkout events include each line item, the total and the currency, and purchases also include the order id. For Google, LinkedIn and X, Pozy saves the ids of their conversion events at install time, so reinstall whenever you add or edit conversion events there. Each event has an event id, `shopify_order_` in the case of a purchase. If you also report the purchase from your server with [`POST /v1/ads/conversions`](https://pozy.ai/docs/api/sendConversions), reuse that `eventId` and the platforms will count the order only once. The pixel respects the store's customer privacy settings: in places where consent is needed, it only fires for visitors who accepted marketing. Stores connected through a custom-app token, or connected before they granted `write_pixels` and `read_customer_events`, get `409 reconnect_required`. `details.authUrl` holds a link for the merchant to approve, and `details.missingScopes` lists what is missing. The account id does not change, so just repeat the install once they approve. ### WordPress Pozy places the platform's base code in a Custom HTML widget inside one of the active theme's widget areas, preferring a footer area and otherwise using the first area that is active; `sidebarId` lets you pick. It then reads the widget again to make sure WordPress did not strip the script. With `verifyHomepage`, which is on by default, it also loads the homepage and reports `homepageCheck` as `found`, `not_found`, `unreachable` or `skipped`. A page cache can cause `not_found`; trust the widget read-back. Each tag gets its own widget, and several tags run side by side. Installing again updates the same widget (`created: false`). Code the owner pasted by hand is never touched. The status call also returns a `preflight` (`ready`, `reason`, available `sidebars`) to check a site before installing. WordPress strips `