# List webhooks API Reference

Retrieve all configured webhooks for the authenticated user. Supports up to 50 webhooks per user.

## GET /v1/webhooks/settings

**List webhooks**

Retrieve all configured webhooks for the authenticated user. Supports up to 50 webhooks per user.

### Responses

#### 200: Webhooks retrieved successfully

**Response Body:**

- **webhooks** `array[Webhook]`: 

#### 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: The API key is a restricted key (zrk_ prefix) and may not perform this operation. Three cases:

1. The operation's resource group (see the operation's x-resource-group) is disabled on the key. Fix: create a key with the group enabled in the dashboard API keys tab and revoke the old one.
2. The operation is admin-plane (x-resource-group admin-plane: API keys, invites, connected apps, member identity), never grantable to restricted keys. The error reads "Restricted API keys cannot manage API keys, invites, or member identity." Fix: a full-access key or the dashboard, never a new restricted key.
3. On webhook subscription writes, delivery-log reads and replays, a named event maps to a resource group the key does not hold. A restricted key can never create or edit a subscription broader than itself (a no-messages key cannot subscribe to, test-fire, redeliver or read logs for message.* events).


**Response Body:**

- **error** `string`: No description (example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab.")
- **code** `string`: No description - one of: insufficient_permissions, unclassified_resource
- **required_group** `string`: The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials. - one of: publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks, commerce, creator_campaigns, money, calendar, files

---

## POST /v1/webhooks/settings

**Create webhook**

Create a new webhook configuration. Maximum 50 webhooks per user.

`name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.

### Auto-disable

A webhook is auto-disabled only once the endpoint has had no successful delivery for 3 days AND either:

- has reached 20 consecutive terminal failures (each one an event that exhausted the full retry ladder), or
- has been failing continuously for 3 days.

The owner is emailed; re-enable it with `isActive: true`.

### Restricted keys

A restricted (zrk_) API key can only subscribe to events whose resource group the key holds. An event outside the key's groups is rejected with 403, so a restricted key can never create a subscription broader than itself.

### Filtering what is delivered

`disabledResourceGroups` restricts the subscription itself, independently of which key or session later reads it. Events in a disabled group are dropped before delivery to this endpoint, even if they are listed in `events`. This applies on live delivery and on every replay path (test fire, redelivery, dead-letter requeue).

Omit it to receive everything in `events`, which is how existing subscriptions behave. A restricted key's own disabled groups are always unioned in.

`profileIds` pins the subscription to a set of profiles and `accountIds` to a set of connected accounts. Only events attributable to one of the listed ids are delivered, and a subscription with both lists must match on both. Use them to send test accounts to a staging endpoint.

Ids outside your team are rejected with 404 (`profile_not_found`, `account_not_found`).


### Request Body

- **name** (required) `string`: Webhook name (1-50 characters)
- **url** (required) `string`: Webhook endpoint URL (must be a valid URL, whitespace trimmed)
- **secret** `string`: Secret key for HMAC-SHA256 signature verification
- **events** (required) `array`: Events to subscribe to (at least one required)
- **isActive** `boolean`: Enable or disable webhook delivery. Defaults to `true` when omitted.
- **customHeaders** `object`: Custom headers to include in webhook requests
- **disabledResourceGroups** `array`: Resource groups this subscription does not receive (opt-out denylist). Omit or send an empty array to receive every event in `events`.

- Listing a group here drops its events before delivery and on every replay path.
- Set at creation, it applies to everything this subscription ever receives.
- Changed later via PUT, it applies to events emitted after the change, with a five-minute tail for events already queued (see that operation).
- For a restricted (zrk_) key, that key's own disabled groups are unioned into whatever you send here, so a restricted key can never create a subscription wider than itself.

- **profileIds** `array`: Profiles this subscription receives events for. Omit or send an empty array to receive every profile. Every id must be a profile in your team, otherwise the request fails with 404 `profile_not_found` and nothing is created. Typical use is routing the profile that holds test accounts to a staging endpoint.
- **accountIds** `array`: Connected accounts this subscription receives events for. Omit or send an empty array to receive every account. Every id must be an account in your team, otherwise the request fails with 404 `account_not_found` and nothing is created. Combine with `profileIds` to narrow further; both must match.

### Responses

#### 200: Webhook created successfully

**Response Body:**

- **success** `boolean`: No description
- **webhook**: `Webhook` - See schema definition

#### 400: Validation error or maximum webhooks reached

#### 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: The API key is a restricted key (zrk_ prefix) and may not perform this operation. Three cases:

1. The operation's resource group (see the operation's x-resource-group) is disabled on the key. Fix: create a key with the group enabled in the dashboard API keys tab and revoke the old one.
2. The operation is admin-plane (x-resource-group admin-plane: API keys, invites, connected apps, member identity), never grantable to restricted keys. The error reads "Restricted API keys cannot manage API keys, invites, or member identity." Fix: a full-access key or the dashboard, never a new restricted key.
3. On webhook subscription writes, delivery-log reads and replays, a named event maps to a resource group the key does not hold. A restricted key can never create or edit a subscription broader than itself (a no-messages key cannot subscribe to, test-fire, redeliver or read logs for message.* events).


**Response Body:**

- **error** `string`: No description (example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab.")
- **code** `string`: No description - one of: insufficient_permissions, unclassified_resource
- **required_group** `string`: The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials. - one of: publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks, commerce, creator_campaigns, money, calendar, files

#### 404: A profile in `profileIds` (`profile_not_found`) or an account in `accountIds` (`account_not_found`) is not in your team

---

## PUT /v1/webhooks/settings

**Update webhook**

Update an existing webhook configuration. All fields except `webhookId` are optional; only provided fields will be updated.

`webhookId` is the same name the other /v1/webhooks operations use (logs, redeliver, test). The deprecated `_id` is still accepted in its place.

### Validation

When provided:

- `name` must be 1-50 characters.
- `url` must be a valid URL. Whitespace is trimmed from `url` before validation.
- `events` must contain at least one event.

### Auto-disable

Webhooks are auto-disabled only once the endpoint has had no successful delivery for 3 days AND either:

- has reached 20 consecutive terminal failures (each one an event that exhausted the full retry ladder), or
- has been failing continuously for 3 days.

The owner is emailed. Re-enable it with `isActive: true`.

### Restricted keys

A restricted (zrk_) API key can only set `events` to events whose resource group the key holds. An event outside the key's groups is rejected with 403. It also cannot widen an existing subscription past its own groups.

### `disabledResourceGroups`

`disabledResourceGroups` replaces the subscription's own denylist, which applies to delivery regardless of which key or session created it. Send an empty array to clear it.

A restricted key's own disabled groups are unioned into the stored value on every update. So repointing a legacy unrestricted subscription with a restricted key also narrows it.

### Denylist timing

The new denylist applies to every event emitted after the update.

Events already queued for delivery when the update landed were filtered against the previous denylist. They can still arrive at your endpoint for up to five minutes after they were enqueued, because the delivery worker trusts a five-minute enqueue-time snapshot before re-checking the subscription.

Retries beyond that window, dead-letter replays, test fires, and redeliveries are all checked against the current denylist.

### Allowlists

`profileIds` and `accountIds` replace the subscription's allowlists; an empty array clears one. Ids outside your team are rejected with 404 (`profile_not_found`, `account_not_found`).


### Request Body

- **webhookId** `string`: Webhook ID to update. Required unless the deprecated `_id` is sent instead.
- **_id** `string`: Alias of webhookId, kept for existing callers
- **name** `string`: Webhook name (1-50 characters). Must be non-empty if provided.
- **url** `string`: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided.
- **secret** `string`: Secret key for HMAC-SHA256 signature verification
- **events** `array`: Events to subscribe to. Must contain at least one event if provided.
- **isActive** `boolean`: Enable or disable webhook delivery
- **customHeaders** `object`: Custom headers to include in webhook requests
- **disabledResourceGroups** `array`: Replaces the subscription's denylist.

- Send an empty array to clear it and receive every event in `events` again.
- Omit the field to leave the current denylist untouched.
- Applies to events emitted after the update; already-queued events can still deliver for up to five minutes after they were enqueued.
- With a restricted (zrk_) key, that key's own disabled groups are unioned back in either way, so it can neither clear nor widen a subscription past its own groups.

- **profileIds** `array`: Replaces the subscription's profile allowlist. Applies to events emitted after the update.

- Empty array: receive every profile again.
- Omitted: the current list is left untouched.
- Every id must be a profile in your team, otherwise the request fails with 404 `profile_not_found` and nothing changes.

Sending the stored list back unchanged is accepted without re-validation, so an endpoint stays editable after a listed profile is deleted.

- **accountIds** `array`: Replaces the subscription's account allowlist.

- Send an empty array to receive every account again.
- Omit the field to leave the current list untouched.
- Every id must be an account in your team, otherwise the request fails with 404 `account_not_found` and nothing changes.
- Sending the stored list back unchanged is accepted without re-validation.


### Responses

#### 200: Webhook updated successfully

**Response Body:**

- **success** `boolean`: No description
- **webhook**: `Webhook` - See schema definition

#### 400: Validation error or missing webhook ID

#### 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: The API key is a restricted key (zrk_ prefix) and may not perform this operation. Three cases:

1. The operation's resource group (see the operation's x-resource-group) is disabled on the key. Fix: create a key with the group enabled in the dashboard API keys tab and revoke the old one.
2. The operation is admin-plane (x-resource-group admin-plane: API keys, invites, connected apps, member identity), never grantable to restricted keys. The error reads "Restricted API keys cannot manage API keys, invites, or member identity." Fix: a full-access key or the dashboard, never a new restricted key.
3. On webhook subscription writes, delivery-log reads and replays, a named event maps to a resource group the key does not hold. A restricted key can never create or edit a subscription broader than itself (a no-messages key cannot subscribe to, test-fire, redeliver or read logs for message.* events).


**Response Body:**

- **error** `string`: No description (example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab.")
- **code** `string`: No description - one of: insufficient_permissions, unclassified_resource
- **required_group** `string`: The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials. - one of: publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks, commerce, creator_campaigns, money, calendar, files

#### 404: Webhook not found, or a profile in `profileIds` (`profile_not_found`) / an account in `accountIds` (`account_not_found`) is not in your team

---

## DELETE /v1/webhooks/settings

**Delete webhook**

Permanently delete a webhook configuration.

### Parameters

- **webhookId** (optional) in query: Webhook ID to delete, the same name the other /v1/webhooks operations use (logs, redeliver, test). Required unless the deprecated `id` is sent instead.
- **id** (optional) in query: Alias of webhookId, kept for existing callers

### Responses

#### 200: Webhook deleted successfully

**Response Body:**

- **success** `boolean`: No description

#### 400: webhookId missing or not a valid ID, or a query parameter this endpoint does not know (the message lists the accepted ones; nothing is silently ignored)

#### 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: The API key is a restricted key (zrk_ prefix) and may not perform this operation. Three cases:

1. The operation's resource group (see the operation's x-resource-group) is disabled on the key. Fix: create a key with the group enabled in the dashboard API keys tab and revoke the old one.
2. The operation is admin-plane (x-resource-group admin-plane: API keys, invites, connected apps, member identity), never grantable to restricted keys. The error reads "Restricted API keys cannot manage API keys, invites, or member identity." Fix: a full-access key or the dashboard, never a new restricted key.
3. On webhook subscription writes, delivery-log reads and replays, a named event maps to a resource group the key does not hold. A restricted key can never create or edit a subscription broader than itself (a no-messages key cannot subscribe to, test-fire, redeliver or read logs for message.* events).


**Response Body:**

- **error** `string`: No description (example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab.")
- **code** `string`: No description - one of: insufficient_permissions, unclassified_resource
- **required_group** `string`: The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials. - one of: publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks, commerce, creator_campaigns, money, calendar, files

---


# Related Schema Definitions

## Webhook

Individual webhook configuration for receiving real-time notifications

### Properties

- **_id** `string`: Unique webhook identifier
- **name** `string`: Webhook name (for identification) (max: 50)
- **url** `string`: Webhook endpoint URL
- **secret** `string`: Secret key for HMAC-SHA256 signature verification.
- **events** `array`: Events subscribed to
- **isActive** `boolean`: Whether webhook delivery is enabled
- **lastFiredAt** `string`: Timestamp of the last successful webhook delivery. On a healthy endpoint it is refreshed at most once every 10 minutes, so it can trail the most recent delivery by up to 10 minutes.
- **failureCount** `integer`: Consecutive terminal delivery failures. Resets to 0 on any successful delivery.

Auto-disable triggers only when the endpoint has had no successful delivery within a 3-day window AND either reaches 20 consecutive terminal failures or has been failing continuously for 3 days. Any success within that window keeps the endpoint enabled, regardless of the count.

- **customHeaders** `object`: Custom headers included in webhook requests
- **disabledResourceGroups** `array`: Resource groups this subscription does not receive (opt-out denylist, same vocabulary and semantics as the field on API keys).

- Absent or empty: the subscription receives every event in `events` (how subscriptions created before this field behave).
- An event whose group is listed is dropped before delivery, even when still in `events`. The same check runs on every replay path (test fire, redelivery, dead-letter requeue).
- Edits apply to every event emitted afterwards. Events already queued when the edit landed can still be delivered for up to five minutes after they were enqueued.

- **profileIds** `array`: Profiles this subscription receives events for (allowlist). Absent or empty means every profile, which is how every subscription created before this field existed behaves.

- A scoped subscription only gets events attributable to a listed profile.
- An aggregate `post.*` event is attributed to the profile of every account the post targets, so a post spanning two scoped endpoints' profiles reaches both.
- Events with no profile behind them (`verification.*`, `phone_number.*`, a legacy post whose accounts were deleted) are not delivered to it.

Applied when the event is emitted: a redelivery replays a delivery already made to this endpoint, and a test fire ignores the list.

- **accountIds** `array`: Connected accounts this subscription receives events for (allowlist). Absent or empty means every account.

- Same semantics as `profileIds`, keyed on the account: an aggregate `post.*` event is attributed to every account the post targets.
- Events that name no connected account (`verification.*`, `phone_number.*`, and `whatsapp.number.*`, which carry the phone number) are not delivered to an account-scoped subscription.
- A subscription with both lists must be satisfied on both.
- Applied when the event is emitted. A redelivery replays a delivery already made to this endpoint, and a test fire ignores the list.
- The shared WhatsApp sandbox account id (listed by `GET /v1/accounts?includeSandbox=true`) is accepted, so a subscription can receive sandbox traffic only.


## 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