# List conversations API Reference

Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.

Supported platforms: Facebook, Instagram, X, Bluesky, Reddit, Telegram, WhatsApp, iMessage and SMS.

### X limitation

X has replaced traditional DMs with encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not accessible via X's API (the /2/dm_events endpoint only returns legacy unencrypted DMs).

This means some X conversations may show only outgoing messages or appear empty. This is an X platform limitation that affects all third-party applications. See X's docs on encrypted messaging for more details.

### Instagram and Facebook pre-connect history

When one of these accounts is connected, Zernio replays the DM history the account already holds on Meta, so conversations that began before the account was connected appear here. Up to 500 conversations per account are replayed.

- The replay runs in the background and can finish after a listing you have already taken.
- Replayed conversations keep their original lastMessageAt, so they sort into date order rather than appearing at the top.
- If you mirror this endpoint into your own store, re-run the sweep rather than relying on a single pass at connect time.
- Replayed history emits no webhooks and is stored as already read, so it never affects unread counts.
- Threads that Meta refuses to serve are skipped.
- An account whose Instagram "connected tools" message access is turned off is not replayed at all.


## GET /v1/inbox/conversations

**List conversations**

Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.

Supported platforms: Facebook, Instagram, X, Bluesky, Reddit, Telegram, WhatsApp, iMessage and SMS.

### X limitation

X has replaced traditional DMs with encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not accessible via X's API (the /2/dm_events endpoint only returns legacy unencrypted DMs).

This means some X conversations may show only outgoing messages or appear empty. This is an X platform limitation that affects all third-party applications. See X's docs on encrypted messaging for more details.

### Instagram and Facebook pre-connect history

When one of these accounts is connected, Zernio replays the DM history the account already holds on Meta, so conversations that began before the account was connected appear here. Up to 500 conversations per account are replayed.

- The replay runs in the background and can finish after a listing you have already taken.
- Replayed conversations keep their original lastMessageAt, so they sort into date order rather than appearing at the top.
- If you mirror this endpoint into your own store, re-run the sweep rather than relying on a single pass at connect time.
- Replayed history emits no webhooks and is stored as already read, so it never affects unread counts.
- Threads that Meta refuses to serve are skipped.
- An account whose Instagram "connected tools" message access is turned off is not replayed at all.


### Parameters

- **profileId** (optional) in query: Filter by profile ID
- **platform** (optional) in query: Filter by platform
- **status** (optional) in query: Filter by conversation status
- **folder** (optional) in query: `requests` lists Facebook Page Message Requests (threads from people the account has not accepted) live from Meta, each item with `folder: requests`.

Meta has no accept call: replying moves a thread to the inbox, which is what POST /v1/inbox/conversations/{conversationId}/accept does.

- When Meta will not list the folder for the one account asked (`accountId`), the call answers 400 PLATFORM_LIMITATION. Across several accounts, the refusal is reported per account in meta.failedAccounts.
- Instagram accounts (Instagram Login and Facebook Login) answer PLATFORM_LIMITATION, because Meta does not separate the folder on Instagram. Only Facebook Pages are served.

One page of up to `limit` threads. `pagination.hasMore` is true when threads past `limit` were cut (narrow with `accountId`). `pagination.nextCursor` is always null and a `cursor` answers 400.

- **sortOrder** (optional) in query: Sort order by updated time
- **limit** (optional) in query: Maximum number of conversations to return; values above 100 are served as 100.
- **cursor** (optional) in query: Pagination cursor for next page
- **accountId** (optional) in query: Filter by specific account ID

### Responses

#### 200: Aggregated conversations

**Response Body:**

- **data** `array[object]`: 
  - **id** `string`: Opaque conversation identifier. Pass it back verbatim to any /v1/inbox/conversations/{conversationId} route; do not assume a fixed format.
  - **platform** `string`: No description
  - **accountId** `string`: No description
  - **accountUsername** `string`: No description
  - **participantId** `string`: No description
  - **participantName** `string`: No description
  - **participantPicture** `string,null`: No description
  - **participantVerifiedType** `string,null`: X verified badge type. Only present for X conversations. - one of: blue, government, business, none
  - **businessScopedUserId** `string`: WhatsApp only. Meta business-scoped user ID (BSUID), the stable identity anchor; present when Meta has sent it for this participant.
  - **whatsappUsername** `string`: WhatsApp only. The participant's WhatsApp username (e.g. `jane.shop`, no leading @). Not a stable identifier, because users can change it: useful for display, not recommended as an identity anchor. Captured from inbound messages, so older threads fill in on their next inbound.
  - **lastMessage** `string`: No description
  - **updatedTime** `string` (date-time): No description
  - **status** `string`: No description - one of: active, archived
  - **unreadCount** `integer,null`: Number of unread incoming messages, capped at 1000. Read from a replica, so it can trail a conversation you just marked read by a few seconds.
  - **threadControl** `string`: Present once a handover has touched the thread (WhatsApp, Facebook, Instagram). ai_agent: Meta Business Agent answers (WhatsApp) and new inbound arrive flagged metadata.standby; app: you hold control; other: another app does (a WhatsApp partner, or a Messenger / Instagram receiver such as Page Inbox). Change it with POST /v1/inbox/conversations/{conversationId}/thread-control. - one of: app, ai_agent, other
  - **folder** `string`: Present only on items listed with folder=requests: a Message Request the account has not accepted yet. - one of: requests
  - **isGroup** `boolean`: iMessage only, true for a group thread. Manage it through the /v1/imessage/groups/{conversationId} endpoints.
  - **url** `string,null`: Direct link to open the conversation on the platform (if available)
  - **instagramProfile** `object,null`: Instagram profile data for the participant. Only present for Instagram conversations.
  - **metadata**: `InboxConversationAdMetadata` - See schema definition
  - **adAttribution**: `ConversationAdAttribution` - See schema definition
- **pagination** `object`: 
  - **hasMore** `boolean`: No description
  - **nextCursor** `string,null`: No description
- **meta** `object`: 
  - **accountsQueried** `integer`: No description
  - **accountsFailed** `integer`: No description
  - **failedAccounts** `array[object]`: 
    - **accountId** `string`: No description
    - **accountUsername** `string,null`: No description
    - **platform** `string`: No description
    - **error** `string`: No description
    - **code** `string,null`: Error code if available
    - **retryAfter** `integer,null`: Seconds to wait before retry (rate limits)
  - **lastUpdated** `string` (date-time): No description
  - **accountsSkipped** `array[object]`: Connected accounts that were not queried: their platform does not support this feature, or the account is not enabled for it
    - **accountId** `string`: No description
    - **platform** `string`: No description

#### 400: Invalid query, a platform without direct messages (PLATFORM_NOT_SUPPORTED), or folder=requests on one account Meta will not list the folder for (PLATFORM_LIMITATION).

**Response Body:**

- **error** `string`: Human-readable error message.
- **type** `string`: Error class for programmatic handling. - one of: invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error
- **code** `string`: Stable machine-readable error code.
- **param** `string`: The request field that caused the error, when applicable.
- **docUrl** `string`: Documentation page for resolving the error, when one applies.
- **platform** `string`: Upstream platform (e.g. meta, google, tiktok), present when type is platform_error.
- **platformError** `object`: Raw error payload from the upstream platform, passed through verbatim so
integrators can read provider-specific codes. For Meta this includes
error_subcode, error_user_title, and error_user_msg.

- **details** `object`: Additional structured context for the error (for example field-level validation errors). What it carries depends on the endpoint and platform.

**Meta ads refusals.** The ones customers hit most come back with their own `code` instead of `platform_api_error`, the field to fix in `param`, and `details.fix` ({ label, url }) pointing at the Meta page that resolves it:

- `ad_account_payment_required`
- `ad_account_policy_not_accepted`
- `meta_security_check_required`
- `meta_action_blocked`
- `whatsapp_number_not_linked`
- `invalid_targeting`

Meta's own error stays in the message and in `platformError`.

**Private replies.** `privateReplyConsumed` appears on the private-reply endpoint's 400 when the comment's single reply is already spent. `privateReplyOutcome: "unknown"` appears on its 502 when Meta answered a server error and the reply may have been delivered.

**Google quota 429.** On a Google Ads or YouTube Analytics 429, `quotaExhausted: true` marks the failure as Google's own quota, not a Zernio rate limit, so you can keep calling other platforms instead of backing off everywhere. When Google names the scope, `quotaScope` is also set:

- `DEVELOPER`: the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics). Every account on Zernio is affected and there is nothing to change on your side.
- `ACCOUNT`: your own ad account (Google Ads only).

A Meta 429 carries neither field.

**Zernio Google Ads burst-limit 429.** Carries `budgetScope` instead, never `quotaExhausted`. These are Zernio's own limits, applied before the call reaches Google, and both clear within a minute. They are separate axes from `quotaScope`, not the same pool named twice.

- `account`: the limit of 15 requests per minute per connected Google Ads account.
- `user`: the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts.

**Failed Meta ad create** (`POST /v1/ads/create`, `POST /v1/ads/boost`, `POST /v1/ads/ctwa`). Carries `stage` (where it failed), `adAccountId` (on which ad account) and `createdObjects` (every object this request had already created, with what cleanup did to it).

- `left_behind` objects still exist on the ad account (Meta refused the delete, typically on a held account, or the delete hit a network error). Delete them yourself or reuse them.
- `unconfirmedWrite` is set when Meta answered a create with a 5xx or dropped the connection and Zernio could not confirm whether the object exists. Check that parent before creating it again.

**Ineligible ad account: 422 `ad_account_unusable`.** When Meta refuses a write because the ad account's status is ineligible to manage ads (Meta subcode 2490592 or 4841021, on the write itself or on a cleanup delete), any Meta ads write answers it.

That covers create, boost, CTWA, campaign / ad set / ad updates and status changes.

The message names the ad account when known, links Meta's fix (https://www.facebook.com/business/help/422289316306981), lists the objects left behind and ends with Meta's original error, which also stays verbatim in `platformError`.

Meta often reports the first failure under an unrelated code (e.g. 1487194 on the creative), so branch on `code`, not on `platformError`.

**Missing Advertise permission: 403 `insufficient_permissions`.** When the Facebook user behind the connection's token lacks the Advertise permission on the ad account, any Meta ads write answers it.

Triggers: Meta subcode 2490585, 2490591 or 4841020 on the write, or a code 200 refusal such as 1487194 whose cleanup deletes Meta refused with 2490591 or 4841020.

Meta may still accept the campaign and ad set and refuse only the ad. The message names the ad account, asks for the Advertise or Manage permission on it (or a token granted `ads_management`, by reconnecting) and lists the objects left behind.

It ends with Meta's `error_user_title`, `error_user_msg` and subcode; the raw error stays in `platformError`.

  - **stage** `string`: Meta ad create failures only. The step that failed:

- `media`: image/video download or upload.
- `campaign`
- `adset`
- `creative`
- `ad`: the ad POST itself, where Meta's code 31 / 3858385 hold and 100 / 1359188 payment rejections land.
- `activation`: switching the created objects on. No longer returned: a refused switch to ACTIVE now answers 201 with an `activation_failed:` warning and the objects kept PAUSED.
- `other`: a read or check before any write.
 - one of: media, campaign, adset, creative, ad, activation, other
  - **adAccountId** `string`: Meta ad create failures only. The ad account the request wrote to (`act_...`).
  - **createdObjects** `array[object]`: Meta ad create failures only. Every object this request created before failing, in creation order. Objects you referenced (an existing campaign, ad set, creative or video) are never listed and never deleted.
    - **type** `string`: No description - one of: campaign, adset, creative, ad, video, image
    - **id** `string`: Meta object id; for `image` the image hash.
    - **cleanup** `string`: `deleted`: Zernio deleted it. `left_behind`: cleanup could not delete it (Meta refused the delete, or the delete hit a network error), so it still exists. `kept`: deliberately not deleted (image hashes are shared by every upload of the same file). - one of: deleted, left_behind, kept
  - **unconfirmedWrite** `object`: Meta ad create failures only, when Meta answered a create with a 5xx or dropped the connection and the object could not be looked up. Zernio did not retry it.
    - **type** `string`: No description - one of: campaign, adset, creative, ad
    - **parentId** `string`: Where to look for it: the ad set for an ad, the campaign for an ad set, the ad account otherwise.
  - **quotaExhausted** `boolean`: Google Ads and YouTube Analytics 429 only. True when the upstream Google quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads and YouTube Analytics 429 only, when the scope is known. DEVELOPER is the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics); ACCOUNT is your ad account (Google Ads only). - one of: DEVELOPER, ACCOUNT
  - **budgetScope** `string`: Zernio Google Ads burst-limit 429 only (never set alongside `quotaExhausted`). `account` is the limit of 15 requests per minute per connected Google Ads account; `user` is the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts. Both clear within a minute. - one of: account, user

#### 401: Missing or invalid API key. `code` is `missing_credentials` when no Authorization header was sent and `invalid_credentials` when the key is unknown, revoked or expired.

**Response Body:**

- **error** `string`: Human-readable error message.
- **type** `string`: Error class for programmatic handling. - one of: invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error
- **code** `string`: Stable machine-readable error code.
- **param** `string`: The request field that caused the error, when applicable.
- **docUrl** `string`: Documentation page for resolving the error, when one applies.
- **platform** `string`: Upstream platform (e.g. meta, google, tiktok), present when type is platform_error.
- **platformError** `object`: Raw error payload from the upstream platform, passed through verbatim so
integrators can read provider-specific codes. For Meta this includes
error_subcode, error_user_title, and error_user_msg.

- **details** `object`: Additional structured context for the error (for example field-level validation errors). What it carries depends on the endpoint and platform.

**Meta ads refusals.** The ones customers hit most come back with their own `code` instead of `platform_api_error`, the field to fix in `param`, and `details.fix` ({ label, url }) pointing at the Meta page that resolves it:

- `ad_account_payment_required`
- `ad_account_policy_not_accepted`
- `meta_security_check_required`
- `meta_action_blocked`
- `whatsapp_number_not_linked`
- `invalid_targeting`

Meta's own error stays in the message and in `platformError`.

**Private replies.** `privateReplyConsumed` appears on the private-reply endpoint's 400 when the comment's single reply is already spent. `privateReplyOutcome: "unknown"` appears on its 502 when Meta answered a server error and the reply may have been delivered.

**Google quota 429.** On a Google Ads or YouTube Analytics 429, `quotaExhausted: true` marks the failure as Google's own quota, not a Zernio rate limit, so you can keep calling other platforms instead of backing off everywhere. When Google names the scope, `quotaScope` is also set:

- `DEVELOPER`: the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics). Every account on Zernio is affected and there is nothing to change on your side.
- `ACCOUNT`: your own ad account (Google Ads only).

A Meta 429 carries neither field.

**Zernio Google Ads burst-limit 429.** Carries `budgetScope` instead, never `quotaExhausted`. These are Zernio's own limits, applied before the call reaches Google, and both clear within a minute. They are separate axes from `quotaScope`, not the same pool named twice.

- `account`: the limit of 15 requests per minute per connected Google Ads account.
- `user`: the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts.

**Failed Meta ad create** (`POST /v1/ads/create`, `POST /v1/ads/boost`, `POST /v1/ads/ctwa`). Carries `stage` (where it failed), `adAccountId` (on which ad account) and `createdObjects` (every object this request had already created, with what cleanup did to it).

- `left_behind` objects still exist on the ad account (Meta refused the delete, typically on a held account, or the delete hit a network error). Delete them yourself or reuse them.
- `unconfirmedWrite` is set when Meta answered a create with a 5xx or dropped the connection and Zernio could not confirm whether the object exists. Check that parent before creating it again.

**Ineligible ad account: 422 `ad_account_unusable`.** When Meta refuses a write because the ad account's status is ineligible to manage ads (Meta subcode 2490592 or 4841021, on the write itself or on a cleanup delete), any Meta ads write answers it.

That covers create, boost, CTWA, campaign / ad set / ad updates and status changes.

The message names the ad account when known, links Meta's fix (https://www.facebook.com/business/help/422289316306981), lists the objects left behind and ends with Meta's original error, which also stays verbatim in `platformError`.

Meta often reports the first failure under an unrelated code (e.g. 1487194 on the creative), so branch on `code`, not on `platformError`.

**Missing Advertise permission: 403 `insufficient_permissions`.** When the Facebook user behind the connection's token lacks the Advertise permission on the ad account, any Meta ads write answers it.

Triggers: Meta subcode 2490585, 2490591 or 4841020 on the write, or a code 200 refusal such as 1487194 whose cleanup deletes Meta refused with 2490591 or 4841020.

Meta may still accept the campaign and ad set and refuse only the ad. The message names the ad account, asks for the Advertise or Manage permission on it (or a token granted `ads_management`, by reconnecting) and lists the objects left behind.

It ends with Meta's `error_user_title`, `error_user_msg` and subcode; the raw error stays in `platformError`.

  - **stage** `string`: Meta ad create failures only. The step that failed:

- `media`: image/video download or upload.
- `campaign`
- `adset`
- `creative`
- `ad`: the ad POST itself, where Meta's code 31 / 3858385 hold and 100 / 1359188 payment rejections land.
- `activation`: switching the created objects on. No longer returned: a refused switch to ACTIVE now answers 201 with an `activation_failed:` warning and the objects kept PAUSED.
- `other`: a read or check before any write.
 - one of: media, campaign, adset, creative, ad, activation, other
  - **adAccountId** `string`: Meta ad create failures only. The ad account the request wrote to (`act_...`).
  - **createdObjects** `array[object]`: Meta ad create failures only. Every object this request created before failing, in creation order. Objects you referenced (an existing campaign, ad set, creative or video) are never listed and never deleted.
    - **type** `string`: No description - one of: campaign, adset, creative, ad, video, image
    - **id** `string`: Meta object id; for `image` the image hash.
    - **cleanup** `string`: `deleted`: Zernio deleted it. `left_behind`: cleanup could not delete it (Meta refused the delete, or the delete hit a network error), so it still exists. `kept`: deliberately not deleted (image hashes are shared by every upload of the same file). - one of: deleted, left_behind, kept
  - **unconfirmedWrite** `object`: Meta ad create failures only, when Meta answered a create with a 5xx or dropped the connection and the object could not be looked up. Zernio did not retry it.
    - **type** `string`: No description - one of: campaign, adset, creative, ad
    - **parentId** `string`: Where to look for it: the ad set for an ad, the campaign for an ad set, the ad account otherwise.
  - **quotaExhausted** `boolean`: Google Ads and YouTube Analytics 429 only. True when the upstream Google quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads and YouTube Analytics 429 only, when the scope is known. DEVELOPER is the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics); ACCOUNT is your ad account (Google Ads only). - one of: DEVELOPER, ACCOUNT
  - **budgetScope** `string`: Zernio Google Ads burst-limit 429 only (never set alongside `quotaExhausted`). `account` is the limit of 15 requests per minute per connected Google Ads account; `user` is the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts. Both clear within a minute. - one of: account, user

#### 403: Inbox addon required

---

## POST /v1/inbox/conversations

**Create conversation**

Start a direct message conversation with a user. If a conversation with that recipient already exists, the message is added to the existing thread.

Supported platforms: X, Bluesky, Reddit, WhatsApp, SMS, Slack, iMessage, and email (Google and Microsoft accounts). Other platforms return PLATFORM_NOT_SUPPORTED.

### Email (Google / Microsoft)

Pass the recipient address as participantId with `subject`, `message` and optionally `cc`. The mail is sent from the mailbox and mirrored into the inbox. The response names the inbox conversation it opened (the provider thread id, usable with the other inbox endpoints).

- 412 `account_scope_missing`: the account was connected without the `email` area
- 409: the account is disconnected
- 402 `payment_required`: Gmail accounts need a card on file

Sends count toward the mailbox's daily cap and never toward managed messages.

### iMessage

Pass the recipient as participantId: a phone number in international format (+15551234567) or an iMessage email. Senders ordered through Zernio can message contacts who never wrote to them.

#### Opening message rules

The first message to such a contact must be plain text from an identifiable person or business opening a conversation.

A cold introduction, including a sales one, is fine ("Hi Mark, this is John from Pratt Media. I help local roofers get more leads. Would you be open to a quick chat?").

It may not contain links, email addresses, phone numbers, prices or attachments (the provider's own rule for opening messages). One that does returns 400 with code `invalid_content` and a message saying what was flagged, and nothing is sent.

#### New contact limits

Each sender may open at most one new conversation every 15 minutes. A new sender warms up, allowing this many new conversations per 24 hours:

- Days 1-2 after activation: 2
- Days 3-4: 5
- Days 5-7: 10
- Days 8-14: 20
- Days 15-21: 30
- After that: 50

Beyond that the request returns 429 with code `new_contact_limit` and a time to retry. The warm-up counts from when a sender ordered through Zernio went live. Senders registered from your own provider account are not warmed up by Zernio.

#### Follow-ups before the contact replies

Until the contact replies, the thread accepts the opening and at most two follow-ups, at least 24 hours apart.

- A follow-up sooner returns 429 `new_contact_limit` with the time to retry.
- A fourth message returns 409 `recipient_must_message_first`.

The provider treats every message to a contact who has not replied as a new conversation.

Follow-ups therefore follow the same content rule and count toward the 15-minute spacing and the daily warm-up like an opening. Once the contact has written, the thread behaves like any other and is not limited.

#### Sender safety

Recipients can report a first message as junk, and reports can get a sender blocked permanently, so keep the reply rate high (above roughly 30%).

- A sender registered with your own provider account may lack the add-on for new contacts. Its sends to them return 409 `recipient_must_message_first`, and an opt-in link is the way in.
- A contact who opted out returns 409 `recipient_opted_out`.

### Slack

Pass a workspace member id as participantId (list them with GET /v1/accounts/{accountId}/slack-members). Zernio opens the DM channel with that member and sends the message. The thread then behaves like any other Slack conversation in the inbox. The member must belong to the connected workspace.

### WhatsApp

This is the endpoint for sending an approved template message to a phone number. Put the recipient phone in participantId and provide:

- templateName
- templateLanguage
- templateParams: variable values for the text header, body and dynamic URL buttons, in that order

A template is required because WhatsApp does not permit freeform messages to open a conversation. A missing template returns TEMPLATE_REQUIRED.

#### Template lookup

Before sending, Zernio must resolve an exact APPROVED template definition matching both templateName and templateLanguage. If Meta rejects that lookup, or the exact approved definition is absent, Zernio sends no message and returns the canonical platform error instead.

Lookup errors use code `platform_api_error`, type `platform_error`, and platform `whatsapp`. Sanitized Meta code, message, and `error_data.details` are returned in `platformError`. `details` identifies `phase: template_lookup`, the query-free endpoint, upstream status, and only safe provider usage or retry headers.

#### Template headers, buttons and cards

- Templates with media headers (image, video, document) are handled automatically: Zernio fills the header at send time with the approved template's sample asset.
- To send a DIFFERENT asset per message (e.g. a distinct invoice PDF for each recipient), pass headerMedia with a public link (or a Meta media id); it overrides the sample for that send.
- A template whose approved header format is LOCATION has no header asset to reconstruct: Meta only accepts the location at send time. Pass headerLocation (latitude and longitude required) whenever such a template is sent. headerMedia and headerLocation cannot both be supplied.
- A button that carries its own value at send time (a copy-code button holding a Pix payment code or a coupon, a flow token) is sent with templateButtonParams, addressed by the button's index. templateParams covers text variables and dynamic URL buttons only.
- CAROUSEL templates take per-card overrides via templateCards, each addressed by the card's card_index. Card body variables restart at {{1}} per card and cannot be expressed in the flat templateParams order.
- Template fields are accepted on the JSON body only, not on multipart requests.

#### Existing threads and the 24-hour window

For a number you already have a thread with, this sends the template into that thread. That makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages).

#### Meta Direct Send

WhatsApp Business Accounts eligible for Meta Direct Send can instead open a conversation with a business-initiated utility text message and no template. Pass category: 'utility' together with message (and no templateName). See the category field below.

### X

#### DM eligibility

Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. Skip this check with skipDmCheck: true if you have already verified eligibility.

#### API tier requirement

DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials.

#### Rate limits (X only)

X's DM API enforces, shared across all DM endpoints:

- 200 requests per 15 minutes per connected X account
- 1,000 per 24 hours per connected X account
- 15,000 per 24 hours per X developer app

These limits do NOT apply to other platforms. WhatsApp sends are governed by Meta's per-number messaging tiers (unique business-initiated conversations per 24 hours) and per-number throughput instead.


### Request Body

- **accountId** (required) `string`: The account ID to send from
- **participantId** `string`: Recipient identifier. Provide either this or participantUsername.

- X: the numeric user ID.
- WhatsApp and SMS: the recipient phone number in international format (digits, country code included).
- Slack: the workspace member id (e.g. U01ABCDEF).
- iMessage: a phone number in international format with the leading +, or an iMessage email.

- **participantUsername** `string`: Recipient handle/username, an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId.
- **message** `string`: Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required. Required when category is set (a Direct Send utility message is a text message). Required on email accounts.
- **subject** `string`: Email accounts only (Google / Microsoft). Subject of the new thread.
- **cc** `array`: Email accounts only (Google / Microsoft). Extra recipients copied on the opening mail.
- **skipDmCheck** `boolean`: X only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs.
- **templateName** `string`: WhatsApp only. Name of the approved template to start the conversation with. Required for WhatsApp unless category is used instead (Direct Send). Cannot be combined with category.
- **category** `string`: WhatsApp only (Meta Direct Send). Combined with message and without templateName, starts the conversation with a business-initiated UTILITY message and no pre-approved template. Meta matches or auto-creates a template asynchronously.

- The WhatsApp Business Account must be eligible for Direct Send; otherwise the send fails with an error telling you to use an approved message template instead.
- Cannot be combined with templateName (templates are already categorized at creation).
- Utility messages only: marketing content is not allowed under this category.
- Accepted on the JSON body only, not on multipart requests.
 - one of: utility
- **linkPreview** `boolean`: WhatsApp only. Set false to send the Direct Send (category: 'utility') text message without a link-preview thumbnail for the first URL in the text.

Defaults to true, which is how every WhatsApp text has been sent to date. Does not apply to template sends. Accepted on the JSON body only, not on multipart requests.

- **templateLanguage** `string`: WhatsApp only. Template language code (e.g. en_US).
- **templateParams** `array`: WhatsApp only. Template variable values as one flat array, in order of appearance across the whole template: text-header variables, then body variables, then one value per dynamic URL button (in button order).

Works with positional placeholders ({{1}}, {{2}}, ...) and named placeholders ({{name}}, {{company}}, as Meta Business Manager creates them); values fill named slots in order of appearance.

Example: a body with {{1}}, {{2}} plus a URL button https://example.com/{{1}} takes three values: [body1, body2, buttonSuffix].

For positional templates the list must cover every slot. Fewer values than the header + body + dynamic URL-button count is rejected with 400 (code INVALID_TEMPLATE_PARAMS) naming the expected split, rather than filling a button URL from the wrong value.

A dynamic URL button covered by templateButtonParams needs no value here, unless another uncovered dynamic URL button follows it (the override applies after slot numbering).

Take no value here:

- Media headers (image, video, document): filled automatically from the approved template. Use headerMedia to override the header asset per send.
- Non-dynamic-URL buttons (copy-code, flow): use templateButtonParams.

- **templateButtonParams** `array`: WhatsApp only. Values for template buttons that take one at send time, each addressed by the button's position in the approved template.

This is the only way to send a copy-code button's payload (a Pix payment code, a coupon) or a flow token, since templateParams is a flat array of text variables covering dynamic URL buttons only.

- A button here overrides what templateParams would derive for that index, so no button is sent twice.
- Repeating an index within this array is rejected with 400.
- Each index must name a button of the matching kind on the approved template. This is checked before the send and returns 400 (INVALID_TEMPLATE_BUTTON_PARAM) rather than a Meta rejection.

- **templateCards** `array`: WhatsApp only. Per-card overrides for a CAROUSEL template, each addressed by the card's card_index.

Carousel card body variables restart at {{1}} per card, so they cannot be expressed in the flat templateParams slot order; use this instead.

Rejected with 400 (INVALID_TEMPLATE_CARD_PARAM): a cardIndex naming a card the approved template lacks, a duplicate cardIndex, or a params count that does not match the card body's token count.

- **headerMedia** `object`: WhatsApp only. Overrides a media-header template's header asset for THIS send, so a template with an image/video/document header can carry a different asset per message (e.g. each recipient their own invoice PDF). Without it, the template's approved sample asset is sent. Provide exactly one of link or id.
- **headerLocation** `object`: WhatsApp only. Required to send a template whose approved header format is LOCATION: Meta only accepts the location's lat/long at send time, never at template creation, so there is nothing to fill in automatically. Cannot be combined with headerMedia (a template has exactly one header).

### Responses

#### 201: Conversation created successfully

**Response Body:**

- **success** `boolean`: No description (example: true)
- **data** `object`: 
  - **messageId** `string`: Platform message ID (dm_event_id)
  - **conversationId** `string`: Platform conversation ID (dm_conversation_id). For WhatsApp, this is Zernio's internal conversation id (24-character hex) which matches the id returned by the list-conversations endpoint and the conversationId in the message.received and conversation.started webhooks; use it to correlate the created thread with inbound events.
  - **participantId** `string`: X numeric user ID of the recipient
  - **participantName** `string,null`: Display name of the recipient
  - **participantUsername** `string,null`: X username of the recipient

#### 400: Validation error or platform not supported. Specific cases:

- PLATFORM_LIMITATION: an attachment the platform does not accept.
- TEMPLATE_REQUIRED: a template is required to start a WhatsApp conversation.
- INVALID_TEMPLATE_PARAMS, INVALID_TEMPLATE_BUTTON_PARAM: template variables do not match the approved definition.
- INVALID_TEMPLATE_HEADER: a required template header is missing or incompatible.
- INVALID_TEMPLATE_CARD_PARAM: templateCards do not match the approved carousel definition.
- category combined with templateName, or used on a non-WhatsApp account.
- DIRECT_SEND_NOT_ELIGIBLE, DIRECT_SEND_BLOCKED: the WhatsApp Business Account is not eligible for Direct Send; Meta must grant or restore access.
- DIRECT_SEND_LIMITED: temporary, lifts on its own.
- invalid_content: an iMessage first message to a new contact breaks the content rule.


**Response Body:**


#### 401: Missing or invalid API key. `code` is `missing_credentials` when no Authorization header was sent and `invalid_credentials` when the key is unknown, revoked or expired.

**Response Body:**

- **error** `string`: Human-readable error message.
- **type** `string`: Error class for programmatic handling. - one of: invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error
- **code** `string`: Stable machine-readable error code.
- **param** `string`: The request field that caused the error, when applicable.
- **docUrl** `string`: Documentation page for resolving the error, when one applies.
- **platform** `string`: Upstream platform (e.g. meta, google, tiktok), present when type is platform_error.
- **platformError** `object`: Raw error payload from the upstream platform, passed through verbatim so
integrators can read provider-specific codes. For Meta this includes
error_subcode, error_user_title, and error_user_msg.

- **details** `object`: Additional structured context for the error (for example field-level validation errors). What it carries depends on the endpoint and platform.

**Meta ads refusals.** The ones customers hit most come back with their own `code` instead of `platform_api_error`, the field to fix in `param`, and `details.fix` ({ label, url }) pointing at the Meta page that resolves it:

- `ad_account_payment_required`
- `ad_account_policy_not_accepted`
- `meta_security_check_required`
- `meta_action_blocked`
- `whatsapp_number_not_linked`
- `invalid_targeting`

Meta's own error stays in the message and in `platformError`.

**Private replies.** `privateReplyConsumed` appears on the private-reply endpoint's 400 when the comment's single reply is already spent. `privateReplyOutcome: "unknown"` appears on its 502 when Meta answered a server error and the reply may have been delivered.

**Google quota 429.** On a Google Ads or YouTube Analytics 429, `quotaExhausted: true` marks the failure as Google's own quota, not a Zernio rate limit, so you can keep calling other platforms instead of backing off everywhere. When Google names the scope, `quotaScope` is also set:

- `DEVELOPER`: the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics). Every account on Zernio is affected and there is nothing to change on your side.
- `ACCOUNT`: your own ad account (Google Ads only).

A Meta 429 carries neither field.

**Zernio Google Ads burst-limit 429.** Carries `budgetScope` instead, never `quotaExhausted`. These are Zernio's own limits, applied before the call reaches Google, and both clear within a minute. They are separate axes from `quotaScope`, not the same pool named twice.

- `account`: the limit of 15 requests per minute per connected Google Ads account.
- `user`: the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts.

**Failed Meta ad create** (`POST /v1/ads/create`, `POST /v1/ads/boost`, `POST /v1/ads/ctwa`). Carries `stage` (where it failed), `adAccountId` (on which ad account) and `createdObjects` (every object this request had already created, with what cleanup did to it).

- `left_behind` objects still exist on the ad account (Meta refused the delete, typically on a held account, or the delete hit a network error). Delete them yourself or reuse them.
- `unconfirmedWrite` is set when Meta answered a create with a 5xx or dropped the connection and Zernio could not confirm whether the object exists. Check that parent before creating it again.

**Ineligible ad account: 422 `ad_account_unusable`.** When Meta refuses a write because the ad account's status is ineligible to manage ads (Meta subcode 2490592 or 4841021, on the write itself or on a cleanup delete), any Meta ads write answers it.

That covers create, boost, CTWA, campaign / ad set / ad updates and status changes.

The message names the ad account when known, links Meta's fix (https://www.facebook.com/business/help/422289316306981), lists the objects left behind and ends with Meta's original error, which also stays verbatim in `platformError`.

Meta often reports the first failure under an unrelated code (e.g. 1487194 on the creative), so branch on `code`, not on `platformError`.

**Missing Advertise permission: 403 `insufficient_permissions`.** When the Facebook user behind the connection's token lacks the Advertise permission on the ad account, any Meta ads write answers it.

Triggers: Meta subcode 2490585, 2490591 or 4841020 on the write, or a code 200 refusal such as 1487194 whose cleanup deletes Meta refused with 2490591 or 4841020.

Meta may still accept the campaign and ad set and refuse only the ad. The message names the ad account, asks for the Advertise or Manage permission on it (or a token granted `ads_management`, by reconnecting) and lists the objects left behind.

It ends with Meta's `error_user_title`, `error_user_msg` and subcode; the raw error stays in `platformError`.

  - **stage** `string`: Meta ad create failures only. The step that failed:

- `media`: image/video download or upload.
- `campaign`
- `adset`
- `creative`
- `ad`: the ad POST itself, where Meta's code 31 / 3858385 hold and 100 / 1359188 payment rejections land.
- `activation`: switching the created objects on. No longer returned: a refused switch to ACTIVE now answers 201 with an `activation_failed:` warning and the objects kept PAUSED.
- `other`: a read or check before any write.
 - one of: media, campaign, adset, creative, ad, activation, other
  - **adAccountId** `string`: Meta ad create failures only. The ad account the request wrote to (`act_...`).
  - **createdObjects** `array[object]`: Meta ad create failures only. Every object this request created before failing, in creation order. Objects you referenced (an existing campaign, ad set, creative or video) are never listed and never deleted.
    - **type** `string`: No description - one of: campaign, adset, creative, ad, video, image
    - **id** `string`: Meta object id; for `image` the image hash.
    - **cleanup** `string`: `deleted`: Zernio deleted it. `left_behind`: cleanup could not delete it (Meta refused the delete, or the delete hit a network error), so it still exists. `kept`: deliberately not deleted (image hashes are shared by every upload of the same file). - one of: deleted, left_behind, kept
  - **unconfirmedWrite** `object`: Meta ad create failures only, when Meta answered a create with a 5xx or dropped the connection and the object could not be looked up. Zernio did not retry it.
    - **type** `string`: No description - one of: campaign, adset, creative, ad
    - **parentId** `string`: Where to look for it: the ad set for an ad, the campaign for an ad set, the ad account otherwise.
  - **quotaExhausted** `boolean`: Google Ads and YouTube Analytics 429 only. True when the upstream Google quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads and YouTube Analytics 429 only, when the scope is known. DEVELOPER is the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics); ACCOUNT is your ad account (Google Ads only). - one of: DEVELOPER, ACCOUNT
  - **budgetScope** `string`: Zernio Google Ads burst-limit 429 only (never set alongside `quotaExhausted`). `account` is the limit of 15 requests per minute per connected Google Ads account; `user` is the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts. Both clear within a minute. - one of: account, user

#### 403: Inbox addon required, profile limit reached, or a sandbox send with a template other than the locked sandbox template

#### 404: Account or recipient user not found (Reddit: PARTICIPANT_NOT_FOUND when the u/username does not exist)

**Response Body:**


#### 409: iMessage only. The contact opted out of this sender (recipient_opted_out); or this contact has never written to the sender and either the sender lacks the add-on for new contacts or three messages already went out and the contact has not replied (recipient_must_message_first).

**Response Body:**

- **error** `string`: No description
- **code** `string`: No description - one of: recipient_opted_out, recipient_must_message_first

#### 422: Recipient does not accept DMs from this account (X), or does not accept direct messages from you (Reddit)

**Response Body:**


#### 429: X API rate limit exceeded, Reddit rate limit reached, Meta rejected the WhatsApp template-definition lookup (a lookup failure sends no message), or an iMessage sender reached its new-contact pacing or warm-up limit (new_contact_limit; the error says when to retry).

**Response Body:**


#### 502: The WhatsApp account has no Business Account ID, so the template definition cannot be looked up (a template with no approved variant in that language is a 400). No message was sent.

**Response Body:**

- **error** (required) `string`: No description
- **type** (required) `string`: No description - one of: platform_error
- **code** (required) `string`: No description - one of: platform_api_error
- **platform** (required) `string`: No description - one of: whatsapp
- **platformError** `object`: Sanitized Meta lookup error fields, present when Meta returned them.
  - **code** `integer`: No description
  - **message** `string`: No description
  - **error_data** `object`: 
    - **details** `string`: No description
- **details** (required) `object`: 
  - **phase** (required) `string`: No description - one of: template_lookup
  - **endpoint** (required) `string`: Meta endpoint path without query parameters or access tokens
  - **upstreamStatus** `integer`: No description
  - **providerHeaders** `object`: Safe provider headers retained from the lookup response.
    - **retry-after** `string`: No description
    - **x-app-usage** `string`: No description
    - **x-business-use-case-usage** `string`: No description

#### default: Meta rejected the WhatsApp template-definition lookup with another upstream status. No message was sent.

**Response Body:**

- **error** (required) `string`: No description
- **type** (required) `string`: No description - one of: platform_error
- **code** (required) `string`: No description - one of: platform_api_error
- **platform** (required) `string`: No description - one of: whatsapp
- **platformError** `object`: Sanitized Meta lookup error fields, present when Meta returned them.
  - **code** `integer`: No description
  - **message** `string`: No description
  - **error_data** `object`: 
    - **details** `string`: No description
- **details** (required) `object`: 
  - **phase** (required) `string`: No description - one of: template_lookup
  - **endpoint** (required) `string`: Meta endpoint path without query parameters or access tokens
  - **upstreamStatus** `integer`: No description
  - **providerHeaders** `object`: Safe provider headers retained from the lookup response.
    - **retry-after** `string`: No description
    - **x-app-usage** `string`: No description
    - **x-business-use-case-usage** `string`: No description

---


# Related Schema Definitions

## InboxConversationAdMetadata

Click attribution for a conversation that started from a Meta ad or a ref-tagged ig.me / m.me link, as Meta sent it. Absent when the conversation did not come from an attributable click. For the ad name, campaign and ad set, read `adAttribution`.

**First referral wins.** Values come from the referral Meta delivers for the click. If the same person later arrives through another ad or link, the original values are kept. For the fresh referral per click, read the `message.received` / `referral.received` webhooks.

On Instagram Click-to-Direct, Meta usually delivers the ad post first as an attachment-only `share` / `ig_post` message with no referral, then attaches the referral to the next message.

WhatsApp exception: when Meta omits `ctwa_clid` from the referral, a later Meta automatic event can supply it and refresh `ctwa_captured_at`. Treat `ctwa_captured_at` as when Zernio stored the value, not when the click happened.

**Two key families, one per surface, never together:**

- `ctwa_*`: WhatsApp Click-to-WhatsApp. The ad ID is `ctwa_source_id`. There is no `meta_ad_id` on WhatsApp.
- `meta_ad_*`: Instagram Click-to-Direct, Facebook Messenger Click-to-Message, and ig.me / m.me ref links. The ad ID is `meta_ad_id` (ad clicks only; a link capture carries `meta_ad_ref` without it). `ctwa_clid` never appears here.

Every key is optional and only keys Meta supplied are returned, so read defensively. Meta sends no campaign or ad set ID; those are resolved from your synced ads in `adAttribution`. More keys may be added; treat unknown keys as opaque strings.

Key names differ from the `message.received` webhook on purpose. The webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`), while the stored conversation uses the prefixed names. Renaming either side would break existing integrations, so both spellings stay.


### Properties

- **ctwa_clid** `string`: WhatsApp only. Meta's click identifier, the value to forward to the Meta Conversions API for Business Messaging. Meta omits it on some numbers, so a WhatsApp referral can arrive without it.
- **ctwa_source_id** `string`: WhatsApp only. The Meta ad ID the user clicked (or the post ID when `ctwa_source_type` is post). This is the WhatsApp equivalent of meta_ad_id.
- **ctwa_source_type** `string`: WhatsApp only. What the user clicked, as supplied by Meta (for example ad or post).
- **ctwa_source_url** `string`: WhatsApp only. Meta's URL for the ad that was clicked, normally an fb.me short link.
- **ctwa_headline** `string`: WhatsApp only. Headline of the ad creative at click time.
- **ctwa_body** `string`: WhatsApp only. Body text of the ad creative at click time.
- **ctwa_media_type** `string`: WhatsApp only. Media type of the ad creative, image or video.
- **ctwa_image_url** `string`: WhatsApp only. Image of the ad creative at click time (image ads).
- **ctwa_video_url** `string`: WhatsApp only. Video of the ad creative at click time (video ads).
- **ctwa_thumbnail_url** `string`: WhatsApp only. Thumbnail of the ad video at click time (video ads).
- **ctwa_captured_at** `string`: WhatsApp only. When Zernio stored this referral. Always present when a WhatsApp referral was captured.
- **meta_ad_id** `string`: Instagram and Facebook only. The Meta ad ID the user clicked. Present for ad clicks; absent when the capture came from an ig.me / m.me ref link.
- **meta_ad_source** `string`: Instagram and Facebook only. Meta-supplied source identifier: ADS for ad clicks; SHORTLINK, SHORTLINKS or IGME-SOURCE-LINK for ref links (treat as opaque).
- **meta_ad_type** `string`: Instagram and Facebook only. Meta-supplied referral type, for example OPEN_THREAD.
- **meta_ad_ref** `string`: Instagram and Facebook only. The ref parameter passed through from the ad creative or the ig.me / m.me link.
- **meta_ad_title** `string`: Instagram and Facebook only. Title of the ad creative at click time.
- **meta_ad_photo_url** `string`: Instagram and Facebook only. Image of the ad creative at click time.
- **meta_ad_video_url** `string`: Instagram and Facebook only. Video of the ad creative at click time.
- **meta_ad_post_id** `string`: Instagram and Facebook only. The organic post the ad promoted, when the ad was a boosted post.
- **meta_ad_product_id** `string`: Instagram and Facebook only. The catalogue product the user clicked, for product ads.
- **meta_ad_flow_id** `string`: Instagram and Facebook only. The Meta flow the ad launched, for flow ads.
- **meta_ad_captured_at** `string`: Instagram and Facebook only. When Zernio stored this referral. Always present when an Instagram or Facebook referral was captured.

## ConversationAdAttribution

The ad that opened the conversation, enriched from the ads Zernio syncs for the account owner. null when the conversation did not start from a Meta referral (ad click or ig.me / m.me ref link).

Meta's referral carries the ad ID but no campaign or ad set ID.

- Ad in your synced ads (`GET /v1/ads`): `synced` is true and the ad name, campaign, ad set and ad account come from there.
- Otherwise: `synced` is false, `name` falls back to the ad title (Instagram, Facebook) or headline (WhatsApp) Meta sent with the click, and the other fields are null.

A team member limited to some profiles, or a profile-scoped API key, is enriched only from ads it can list with `GET /v1/ads`; any other ad returns `synced` false.

A non-null object with `adId` null is a referral that is not an ad click: an ig.me / m.me ref link (`name` null) or a WhatsApp click on a post (`ctwa_source_type` post, `name` is Meta's headline). Nothing is looked up and `synced` is false.


### Properties

- **adId** (required) `string,null`: Meta's ad ID: `meta_ad_id` on Instagram and Facebook, `ctwa_source_id` on WhatsApp when `ctwa_source_type` is `ad`. Accepted by `GET /v1/ads/{adId}`.
- **name** (required) `string,null`: The synced ad's name, else the ad title or headline Meta sent.
- **synced** (required) `boolean`: True when the ad was found in your synced ads.
- **campaignId** (required) `string,null`: Meta campaign ID. Synced ads only.
- **campaignName** (required) `string,null`: Campaign name. Synced ads only.
- **adSetId** (required) `string,null`: Meta ad set ID. Synced ads only.
- **adSetName** (required) `string,null`: Ad set name. Synced ads only.
- **adAccountId** (required) `string,null`: Meta ad account ID (act_...). Synced ads only.
- **adAccountName** (required) `string,null`: Ad account name. Synced ads only.
- **platformPostId** (required) `string,null`: The post the ad promotes, from the synced ad or else Meta's `post_id` on the click.
- **isExternal** (required) `boolean,null`: True when the ad was created outside Zernio (synced from Ads Manager), false when created through Zernio, null when not synced.

## ErrorResponse

Canonical error envelope. `error` is the human-readable message; `type`,
`code`, `param`, `platform`, and `platformError` are top-level siblings
for programmatic handling. For upstream platform failures (`type:
platform_error`), `platformError` carries the provider's raw payload
verbatim (for Meta: `error_subcode`, `error_user_title`, `error_user_msg`).


### Properties

- **error** `string`: Human-readable error message.
- **type** `string`: Error class for programmatic handling. - one of: invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error
- **code** `string`: Stable machine-readable error code.
- **param** `string`: The request field that caused the error, when applicable.
- **docUrl** `string`: Documentation page for resolving the error, when one applies.
- **platform** `string`: Upstream platform (e.g. meta, google, tiktok), present when type is platform_error.
- **platformError** `object`: Raw error payload from the upstream platform, passed through verbatim so
integrators can read provider-specific codes. For Meta this includes
error_subcode, error_user_title, and error_user_msg.

- **details** `object`: Additional structured context for the error (for example field-level validation errors). What it carries depends on the endpoint and platform.

**Meta ads refusals.** The ones customers hit most come back with their own `code` instead of `platform_api_error`, the field to fix in `param`, and `details.fix` ({ label, url }) pointing at the Meta page that resolves it:

- `ad_account_payment_required`
- `ad_account_policy_not_accepted`
- `meta_security_check_required`
- `meta_action_blocked`
- `whatsapp_number_not_linked`
- `invalid_targeting`

Meta's own error stays in the message and in `platformError`.

**Private replies.** `privateReplyConsumed` appears on the private-reply endpoint's 400 when the comment's single reply is already spent. `privateReplyOutcome: "unknown"` appears on its 502 when Meta answered a server error and the reply may have been delivered.

**Google quota 429.** On a Google Ads or YouTube Analytics 429, `quotaExhausted: true` marks the failure as Google's own quota, not a Zernio rate limit, so you can keep calling other platforms instead of backing off everywhere. When Google names the scope, `quotaScope` is also set:

- `DEVELOPER`: the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics). Every account on Zernio is affected and there is nothing to change on your side.
- `ACCOUNT`: your own ad account (Google Ads only).

A Meta 429 carries neither field.

**Zernio Google Ads burst-limit 429.** Carries `budgetScope` instead, never `quotaExhausted`. These are Zernio's own limits, applied before the call reaches Google, and both clear within a minute. They are separate axes from `quotaScope`, not the same pool named twice.

- `account`: the limit of 15 requests per minute per connected Google Ads account.
- `user`: the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts.

**Failed Meta ad create** (`POST /v1/ads/create`, `POST /v1/ads/boost`, `POST /v1/ads/ctwa`). Carries `stage` (where it failed), `adAccountId` (on which ad account) and `createdObjects` (every object this request had already created, with what cleanup did to it).

- `left_behind` objects still exist on the ad account (Meta refused the delete, typically on a held account, or the delete hit a network error). Delete them yourself or reuse them.
- `unconfirmedWrite` is set when Meta answered a create with a 5xx or dropped the connection and Zernio could not confirm whether the object exists. Check that parent before creating it again.

**Ineligible ad account: 422 `ad_account_unusable`.** When Meta refuses a write because the ad account's status is ineligible to manage ads (Meta subcode 2490592 or 4841021, on the write itself or on a cleanup delete), any Meta ads write answers it.

That covers create, boost, CTWA, campaign / ad set / ad updates and status changes.

The message names the ad account when known, links Meta's fix (https://www.facebook.com/business/help/422289316306981), lists the objects left behind and ends with Meta's original error, which also stays verbatim in `platformError`.

Meta often reports the first failure under an unrelated code (e.g. 1487194 on the creative), so branch on `code`, not on `platformError`.

**Missing Advertise permission: 403 `insufficient_permissions`.** When the Facebook user behind the connection's token lacks the Advertise permission on the ad account, any Meta ads write answers it.

Triggers: Meta subcode 2490585, 2490591 or 4841020 on the write, or a code 200 refusal such as 1487194 whose cleanup deletes Meta refused with 2490591 or 4841020.

Meta may still accept the campaign and ad set and refuse only the ad. The message names the ad account, asks for the Advertise or Manage permission on it (or a token granted `ads_management`, by reconnecting) and lists the objects left behind.

It ends with Meta's `error_user_title`, `error_user_msg` and subcode; the raw error stays in `platformError`.

  - **stage** `string`: Meta ad create failures only. The step that failed:

- `media`: image/video download or upload.
- `campaign`
- `adset`
- `creative`
- `ad`: the ad POST itself, where Meta's code 31 / 3858385 hold and 100 / 1359188 payment rejections land.
- `activation`: switching the created objects on. No longer returned: a refused switch to ACTIVE now answers 201 with an `activation_failed:` warning and the objects kept PAUSED.
- `other`: a read or check before any write.
 - one of: media, campaign, adset, creative, ad, activation, other
  - **adAccountId** `string`: Meta ad create failures only. The ad account the request wrote to (`act_...`).
  - **createdObjects** `array`: Meta ad create failures only. Every object this request created before failing, in creation order. Objects you referenced (an existing campaign, ad set, creative or video) are never listed and never deleted.
  - **unconfirmedWrite** `object`: Meta ad create failures only, when Meta answered a create with a 5xx or dropped the connection and the object could not be looked up. Zernio did not retry it.
  - **quotaExhausted** `boolean`: Google Ads and YouTube Analytics 429 only. True when the upstream Google quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads and YouTube Analytics 429 only, when the scope is known. DEVELOPER is the shared budget (the developer token for Google Ads, the Google Cloud project for YouTube Analytics); ACCOUNT is your ad account (Google Ads only). - one of: DEVELOPER, ACCOUNT
  - **budgetScope** `string`: Zernio Google Ads burst-limit 429 only (never set alongside `quotaExhausted`). `account` is the limit of 15 requests per minute per connected Google Ads account; `user` is the ceiling of 120 requests per minute per Zernio user across all their Google Ads accounts. Both clear within a minute. - one of: account, user

## WhatsAppTemplateLookupError

### Properties

- **error** (required) `string`: No description
- **type** (required) `string`: No description - one of: platform_error
- **code** (required) `string`: No description - one of: platform_api_error
- **platform** (required) `string`: No description - one of: whatsapp
- **platformError** `object`: Sanitized Meta lookup error fields, present when Meta returned them.
  - **code** `integer`: 
  - **message** `string`: 
  - **error_data** `object`: 
- **details** (required) `object`: 
  - **phase** `string`:  - one of: template_lookup
  - **endpoint** `string`: Meta endpoint path without query parameters or access tokens
  - **upstreamStatus** `integer`: 
  - **providerHeaders** `object`: Safe provider headers retained from the lookup response.