# Boost post as ad API Reference

Turns an already published post into a paid ad that keeps the post's likes and comments, creating the campaign around it.

By default it provisions the whole hierarchy (campaign, ad
set, ad).

**Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING
ad set instead, so that ad set keeps its learning phase. It then owns
`budget`, `schedule` and `targeting`, and sending any of those alongside
`adSetId` is a 400 rather than a silent drop. `budget` is required only
without `adSetId`.

`instagramAccountId`, `destinationType`, `whatsappPhoneNumber` and `adSetId`
are Meta-only and return 400 on other platforms.

`accountId` may be a Facebook, Instagram or Meta ads (business login)
connection. A business-login connection has no posting account, so pass
the post as `platformPostId` (Facebook `pageId_postId` or an Instagram
media id); a Zernio `postId` is a 400 there.

For X, `accountId` may be the posting X account or its X Ads
connection: either resolves the other on the same profile. A Zernio
`postId` on an X Ads connection resolves through its X account, and is
a 400 when the profile has none (pass `platformPostId` instead).

**Reddit.** `accountId` is the Reddit Ads connection (platform `redditads`).
Pass the Reddit post as `platformPostId` (`t3_abc123` or the bare id), or a
Zernio `postId`, which resolves through the Reddit account connected on the
same profile (400 when there is none). Reddit promotes only posts made by a
profile of the ad account: a post by any other author returns 422. Goals are
traffic, awareness, conversions, video_views (the post must be a video, 422
otherwise) and lead_generation; conversions and lead_generation need a pixel
that already received the event (see `promotedObject`). `leadGenFormId` returns
400: Reddit retired its native lead forms, so `lead_generation` sends people to
`linkUrl` and optimizes toward the pixel's Lead event. Each boost creates a
campaign, an ad group carrying the budget, the targeting and the bid, and the ad.
`bidStrategy` sets the ad group's bid: LOWEST_COST_WITHOUT_CAP (or omitted) is
automatic bidding (BIDLESS), LOWEST_COST_WITH_BID_CAP is MANUAL_BIDDING with
`bidAmount` as the bid, COST_CAP is MAXIMIZE_VOLUME with `bidAmount` as the cap; any
other strategy returns 422 before anything is created.

**Messaging boosts (Meta).** Use `goal: engagement` with
`callToAction: WHATSAPP_MESSAGE`, `MESSAGE_PAGE`, or `INSTAGRAM_MESSAGE`.
The CTA implies WHATSAPP, MESSENGER, or INSTAGRAM_DIRECT respectively;
`destinationType` alone does not select a messaging CTA. Omit `linkUrl`
only for messaging CTAs. Plain link CTAs keep their goal and link behavior
when combined with an independent `destinationType`.
The campaign uses OUTCOME_ENGAGEMENT and the ad set uses CONVERSATIONS
with the promoted Page. Optional `whatsappPhoneNumber` selects a number
already paired with that Page. Conflicting CTA/destination, instant form,
goal, or optimizationGoal inputs return 400. Attach requires the target
ad set destination to match. Existing post references preserve social proof;
an Instagram reel rejected by Meta is not re-uploaded as a new post for
a messaging boost.

**Retries.** Boosts are NOT idempotent and can take minutes when Meta requires re-hosting an
Instagram video, so do not retry on client timeout. Send an
Idempotency-Key header to make retries safe: same key and body replays
the original 201, and distinct keys always create distinct ads.
Without the header, an identical request is treated as a retry: while
one is in flight it returns 409, and within 10 minutes of a completed
boost it returns the already-created ad instead of creating another.
To intentionally duplicate an ad, send distinct Idempotency-Keys (or
vary the body, e.g. the name).


## POST /v1/ads/boost

**Boost post as ad**

Turns an already published post into a paid ad that keeps the post's likes and comments, creating the campaign around it.

By default it provisions the whole hierarchy (campaign, ad
set, ad).

**Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING
ad set instead, so that ad set keeps its learning phase. It then owns
`budget`, `schedule` and `targeting`, and sending any of those alongside
`adSetId` is a 400 rather than a silent drop. `budget` is required only
without `adSetId`.

`instagramAccountId`, `destinationType`, `whatsappPhoneNumber` and `adSetId`
are Meta-only and return 400 on other platforms.

`accountId` may be a Facebook, Instagram or Meta ads (business login)
connection. A business-login connection has no posting account, so pass
the post as `platformPostId` (Facebook `pageId_postId` or an Instagram
media id); a Zernio `postId` is a 400 there.

For X, `accountId` may be the posting X account or its X Ads
connection: either resolves the other on the same profile. A Zernio
`postId` on an X Ads connection resolves through its X account, and is
a 400 when the profile has none (pass `platformPostId` instead).

**Reddit.** `accountId` is the Reddit Ads connection (platform `redditads`).
Pass the Reddit post as `platformPostId` (`t3_abc123` or the bare id), or a
Zernio `postId`, which resolves through the Reddit account connected on the
same profile (400 when there is none). Reddit promotes only posts made by a
profile of the ad account: a post by any other author returns 422. Goals are
traffic, awareness, conversions, video_views (the post must be a video, 422
otherwise) and lead_generation; conversions and lead_generation need a pixel
that already received the event (see `promotedObject`). `leadGenFormId` returns
400: Reddit retired its native lead forms, so `lead_generation` sends people to
`linkUrl` and optimizes toward the pixel's Lead event. Each boost creates a
campaign, an ad group carrying the budget, the targeting and the bid, and the ad.
`bidStrategy` sets the ad group's bid: LOWEST_COST_WITHOUT_CAP (or omitted) is
automatic bidding (BIDLESS), LOWEST_COST_WITH_BID_CAP is MANUAL_BIDDING with
`bidAmount` as the bid, COST_CAP is MAXIMIZE_VOLUME with `bidAmount` as the cap; any
other strategy returns 422 before anything is created.

**Messaging boosts (Meta).** Use `goal: engagement` with
`callToAction: WHATSAPP_MESSAGE`, `MESSAGE_PAGE`, or `INSTAGRAM_MESSAGE`.
The CTA implies WHATSAPP, MESSENGER, or INSTAGRAM_DIRECT respectively;
`destinationType` alone does not select a messaging CTA. Omit `linkUrl`
only for messaging CTAs. Plain link CTAs keep their goal and link behavior
when combined with an independent `destinationType`.
The campaign uses OUTCOME_ENGAGEMENT and the ad set uses CONVERSATIONS
with the promoted Page. Optional `whatsappPhoneNumber` selects a number
already paired with that Page. Conflicting CTA/destination, instant form,
goal, or optimizationGoal inputs return 400. Attach requires the target
ad set destination to match. Existing post references preserve social proof;
an Instagram reel rejected by Meta is not re-uploaded as a new post for
a messaging boost.

**Retries.** Boosts are NOT idempotent and can take minutes when Meta requires re-hosting an
Instagram video, so do not retry on client timeout. Send an
Idempotency-Key header to make retries safe: same key and body replays
the original 201, and distinct keys always create distinct ads.
Without the header, an identical request is treated as a retry: while
one is in flight it returns 409, and within 10 minutes of a completed
boost it returns the already-created ad instead of creating another.
To intentionally duplicate an ad, send distinct Idempotency-Keys (or
vary the body, e.g. the name).


### Parameters

- **undefined** (optional): No description

### Request Body

- **creativeFeatures**: No description
- **postId** `string`: Zernio post ID (provide this or platformPostId)
- **platformPostId** `string`: Platform post ID (alternative to postId). Reddit: the post id, with or without the `t3_` prefix. Meta: a Page post id (`<pageId>_<postId>` or the bare post id) or an Instagram media id. On a Meta Ads business-login connection (platform metaads) the id is resolved live: an Instagram media id is boosted as that media, running as the Instagram account that owns it.
- **accountId** (required) `string`: Zernio account id. Normally the connected posting account (facebook, instagram, tiktok, linkedin, pinterest, twitter), a googleads account or a redditads account. TikTok: the TikTok Ads connection (platform tiktokads) is accepted too when the post brings its own authorization (sparkAuthCode or sparkPosts), so Spark ads need no organic TikTok account connected; such a call must use platformPostId, not postId.
- **adAccountId** (required) `string`: Platform ad account ID
- **name** (required) `string`: No description
- **campaignName** `string`: Exact name for the campaign this boost provisions. Omitted keeps the default `<name> - Campaign`. Every platform: on LinkedIn it names the campaign group. Ignored on the Meta attach shape (`adSetId`), which creates no campaign.
- **adSetName** `string`: Exact name for the ad-group level this boost provisions. Omitted keeps the default `<name> - Ad Group`. Meta: ad set; TikTok, Pinterest, Google: ad group; X: line item; LinkedIn: the campaign under the campaign group. Ignored on the Meta attach shape.
- **goal** (required) `string`: Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. Reddit supports traffic, awareness, video_views, lead_generation and conversions. - one of: engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion
- **adSetId** `string`: Meta, or TikTok with `smartPlus: true`. Attach the boosted post to this existing ad set instead of creating a campaign. On TikTok the id is an existing Smart+ ad group: the post is added as one more Spark ad in it (up to 30 per ad group), under the identity its `sparkAuthCode` creates; goal and budget are inherited from the Smart+ campaign; a regular ad group is rejected with a 400. Meta: The ad set then owns budget, schedule and targeting; sending those too is a 400.
- **existingCampaignId** `string`: TikTok only. Create the ad group and the Spark ad under this existing TikTok campaign instead of creating a new campaign. The campaign keeps its own status and objective (the objective must fit `goal`). Cannot be combined with adSetId or smartPlus. On Meta use POST /v1/ads/create with existingCampaignId.
- **identityId** `string`: TikTok only. The identity the ad runs as (the profile shown on the ad), from GET /v1/ads/tiktok-identities. Default: the connected TikTok account's own identity. Must be authorized on the advertiser or the call fails naming the available ones.
- **identityType** `string`: TikTok only. Type of identityId; resolved from the advertiser's identity list when omitted. - one of: TT_USER, CUSTOMIZED_USER, BC_AUTH_TT
- **budgetAmount** `number`: Budget in whole currency units, the same flat field as POST /v1/ads/create. Required unless adSetId is set. Minimum varies: TikTok=$20, Pinterest=$5, others=$1
- **budgetType** `string`: Goes together with budgetAmount. lifetime requires schedule.endDate. - one of: daily, lifetime
- **lifetimeBudgetCap** `number`: X only. A lifetime total, in whole currency units, that caps a daily budget (budgetType daily): the line item gets both daily_budget_amount_local_micro and total_budget_amount_local_micro, and stops spending once the total is reached. Must be at least budgetAmount. Read back as budget.daily and budget.lifetime. 400 on a lifetime budget or on another platform.
- **budget** `object`: Alias of budgetAmount + budgetType, kept for existing callers
- **instagramAccountId** `string`: Meta only. Instagram identity the ad runs AS (creative.instagram_user_id), overriding the account linked to the Page. Live-verified against a Page-post creative.
- **destinationType** `string`: Meta only. Ad-set destination_type: where the click LANDS, as opposed to instagramAccountId which is who the ad runs as. Independent of plain link CTAs and their goal. A messaging callToAction selects its destination automatically; an explicit destinationType must then match. Lead ads use ON_AD. - one of: INSTAGRAM_PROFILE, WEBSITE, ON_AD, MESSENGER, WHATSAPP, INSTAGRAM_DIRECT
- **whatsappPhoneNumber** `string`: Meta WhatsApp only. E.164 number already paired with the Page. Omit to use the default pairing. Requires WHATSAPP_MESSAGE callToAction. Stored as creative.whatsappPhoneNumber on the ad.
- **workflowId** `string`: Meta messaging boosts only (callToAction MESSAGE_PAGE, WHATSAPP_MESSAGE or INSTAGRAM_MESSAGE). A workflow in the account's profile, started in the conversation a click on the ad opens. Stored on the ad. 400 without a messaging callToAction or on another platform, 404 when no such workflow exists in the profile.
- **currency** `string`: ISO 4217 currency code matching the ad account's currency. Meta only. Optional: Zernio resolves it from the ad account when omitted. The value selects the minor-unit exponent Zernio converts budget/bid amounts by before calling Meta (most currencies are cents; zero-decimal currencies like JPY/KRW are sent as-is).
- **startDate** `string`: Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the ad set's `start_time`. When omitted the ad starts delivering immediately. On Meta, TikTok, X, Pinterest and Reddit a value without an offset (`YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS` or `YYYY-MM-DDTHH:MM:SS`) is read in the ad account timezone. Same field as on POST /v1/ads/create.
- **endDate** `string`: Ad-set end time (ISO 8601), mapped to the ad set's `end_time` (on X, the line item's `end_time`; X campaigns carry no flight dates). Required for lifetime budgets. On Meta, TikTok, X, Pinterest and Reddit a value without an offset (`YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS` or `YYYY-MM-DDTHH:MM:SS`) is read in the ad account timezone, and a date-only end runs to 23:59:59 local. Same field as on POST /v1/ads/create.
- **schedule** `object`: Alias of the top-level `startDate` / `endDate`, kept for existing callers. Sending both forms with differing values is a 400.
- **targeting** `object`: Same geo/demographic fields as the `TargetingSpec` used by /v1/ads/create.
Geo keys (`regions`/`cities`/`zips`/`metros`) resolve via
GET /v1/ads/targeting/search?dimension=geo. City radius and lat/lng
`customLocations` are Meta-only and preserve the boosted post's
social proof (the ad references the existing post). On Reddit the whole
`TargetingSpec` applies, including `communities`, `excludedCommunities`,
`conversationKeywords`, `excludedConversationKeywords`, `interests`,
`audienceInclude` / `audienceExclude` and `excludedLocations`; without
any geo the ad targets the US.

- **locationTargetingType**: Google only (400 elsewhere). Written on the campaign the boost creates.
- **rawTargeting** `object`: Meta only. A Meta-native targeting spec (e.g.
`{ "geo_locations": { "cities": [{ "key": "...", "radius": 15, "distance_unit": "kilometer" }] } }`).
Sent alone it is forwarded unchanged. Use for advanced fields the structured
object does not expose (flexible_spec, excluded audiences, business places,
user_os, wireless_carrier).

Can be combined with `targeting`: rawTargeting is the BASE layer and the
built camelCase spec is merged on top, key by key (camelCase wins on
collision). The merge goes one level deep inside `geo_locations` and
`excluded_geo_locations` (built sub-keys win; raw-only sub-keys such as
`location_types` survive). Array values (`flexible_spec`, ...) are replaced
as a whole key, never element-merged.

When `rawTargeting` is present the `advantage_audience: 0` default that
Zernio normally applies is no longer emitted, so it cannot clobber a
`targeting_automation` sent in the raw spec. Meta requires
`targeting_automation` on ad set creation, so include it in the raw spec,
or send `targeting.advantage_audience` (0 or 1), which is merged over raw
as `targeting_automation`.

- **bidStrategy**: Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.

Meta bid strategy applied to the ad set. On TikTok, mapped to
`bid_type` / `bid_price` / `deep_bid_type` automatically. On Reddit,
applied to the new ad group (see the Reddit section above; 422 for
LOWEST_COST_WITH_MIN_ROAS).

- **bidAmount** `number`: Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.

Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing
`bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`.

- **roasAverageFloor** `number`: Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.

Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
`bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers).

- **platformSpecificData**: Platform-specific settings (see schema definitions below)
- **tracking** `object`: Meta only, except `pixelId`, which also picks the Reddit pixel of the new ad group (`promotedObject.pixelId` wins when both are sent). Tracking specs (pixel, URL tags).
- **specialAdCategories** `array`: Meta only. Required for housing, employment, credit, or political ads.
- **specialAdCategoryCountry** `array`: Meta (metaads) only. 2-letter ISO country codes the special ad category applies to. Requires specialAdCategories to be set (400 otherwise).
- **regionalRegulatedCategories** `array`: Meta only. Regional regulation categories required when the ad set targets certain countries (e.g. AUSTRALIA_FINSERV, AUSTRALIA_UNIVERSAL, BRAZIL_REGULATION, INDIA_FINSERV, MALAYSIA_UNIVERSAL, SINGAPORE_UNIVERSAL, TAIWAN_FINSERV, TAIWAN_UNIVERSAL, THAILAND_UNIVERSAL). Forwarded to the ad set.
- **regionalRegulationIdentities** `object`: Meta only. Verified identity IDs of the beneficiary and payer for each declared
regionalRegulatedCategories value. Meta may require them on ad sets created through
the API even when a default beneficiary and payer are set in Ads Manager advertising
settings (seen on AUSTRALIA_FINSERV), so sending them explicitly is the reliable path.
The value is the Verified identity ID (Meta Business Settings > Verified identities),
not a Page or Business ID, and beneficiary and payer may be the same ID.
Send each ID as a string of digits: Verified identity IDs can have 17 digits, more than a
JSON number holds exactly, and a number that arrives rounded is rejected. Numbers up to
9007199254740991 are still accepted. The IDs reach Meta unchanged.
Keys by category: AUSTRALIA_FINSERV uses australia_finserv_beneficiary /
australia_finserv_payer; TAIWAN_FINSERV uses taiwan_finserv_beneficiary /
taiwan_finserv_payer; INDIA_FINSERV uses india_finserv_beneficiary / india_finserv_payer;
TAIWAN_UNIVERSAL uses taiwan_universal_beneficiary / taiwan_universal_payer;
SINGAPORE_UNIVERSAL uses singapore_universal_beneficiary / singapore_universal_payer;
AUSTRALIA_UNIVERSAL, BRAZIL_REGULATION, MALAYSIA_UNIVERSAL and THAILAND_UNIVERSAL use
universal_beneficiary / universal_payer. When Meta rejects the ad set for a missing verified
advertiser, the error carries param regionalRegulationIdentities, details.expectedIdentityKeys
(the keys the declared categories expect), details.suggestedCategories (the markets Meta
ties to that subcode) and Meta's metaCode, metaSubcode and fbtraceId.
Ignored when adSetId adds the ad to an existing ad set.

- **linkUrl** `string`: Website URL for non-messaging CTA buttons. Send it with `callToAction`. Omit for messaging boosts.

**Meta**: adds a top-level `call_to_action` to the post-reference creative.
This is what gives a `traffic` boost a clickable destination without
replacing the creative and losing the post's social proof. Ignored when
`leadGenFormId` is set, which supplies its own destination. Live-verified
against a Page-post creative.

**TikTok**: maps to `landing_page_url` on the Spark Ad creative
(`AdcreateCreatives.landing_page_url`); Spark Ads have no clickable
destination without it.

**Reddit**: the ad's `click_url`, where a click on the promoted post lands.

Ignored on LinkedIn / Pinterest / X / Google, which infer the destination
from the boosted post.

- **callToAction** `string`: CTA button label. Non-messaging CTAs require `linkUrl`.
WHATSAPP_MESSAGE, MESSAGE_PAGE, and INSTAGRAM_MESSAGE do not
require a URL and reject linkUrl.

**Meta**: the CTA enum of POST /v1/ads/create plus
`VIEW_INSTAGRAM_PROFILE`, `WHATSAPP_MESSAGE`, `MESSAGE_PAGE`,
and `INSTAGRAM_MESSAGE`. VIEW_INSTAGRAM_PROFILE requires linkUrl;
the messaging CTAs select their destination automatically.

**TikTok**: pass-through to `call_to_action` on the Spark Ad creative; the
platform validates the value. See TikTok's "Enumeration - Call-to-Action".

- **sparkAuthCode** `string`: TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator
Spark Ads: the advertiser can boost a video owned by a DIFFERENT TikTok
account. Without this, boosts are limited to videos owned by the same
account running the ads (same-BC creators only). The creator generates the
code in their TikTok app's Promote settings and shares it with the
advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.

- **smartPlus** `boolean`: TikTok only. Run the Spark post in a Smart+ campaign (goal `conversions` = Smart+ Web Conversions, `lead_generation` = Smart+ Lead Generation) instead of a regular campaign. Requires `sparkAuthCode` (the Smart+ ad runs the post under the identity that redeeming its Spark code creates; a Business Center-owned post is not accepted there) and `promotedObject.pixelId` + `customEventType`. `app_promotion` is not available on a Spark post. Rejected with a 400 on other platforms. A Smart+ Spark ad uses a dynamic CTA portfolio, sent as ad_configuration.call_to_action_id (TikTok does not accept a named call to action there): Zernio creates one per ad account and reuses it, and `callToAction` is rejected with a 400 on this path.
- **sparkPosts** `array`: TikTok Smart+ only (requires `smartPlus: true`). Several Spark posts as creatives of ONE Smart+ ad, each with its own post code (TikTok allows 1-50 per ad; posts from different creators mix). Replaces `platformPostId` + `sparkAuthCode`. Without `adSetId` it creates campaign + ad group + one ad carrying all of them; with `adSetId` it creates one new ad with all of them in that ad group. Rejected with a 400 on other platforms.
- **promoCodes** `array`: TikTok Smart+ Web Conversions only (requires `smartPlus: true`, goal `conversions`). Promo codes or offers TikTok highlights on the ad (Ads Manager's "Add promo code or offer"). A promo code needs shoppers to enter it at checkout; an entry without `promoCode` is an offer applied automatically. Rejected with a 400 on other platforms and on Lead Generation campaigns.
- **promotedObject** `object`: Meta, TikTok and Reddit. What the conversion ad set optimizes toward, so a boost
of an existing organic post can run for a conversion instead of only
engagement or traffic. Required when `goal` is `conversions` (Meta also
`lead_conversion`); ignored on goals that do not optimize for a conversion.

Meta: `pixelId` + `customEventType` (a commerce event such as PURCHASE
under `conversions`, a leads-class event such as LEAD under
`lead_conversion`), or `customConversionId` to optimize against a Custom
Conversion, or `customEventType: OTHER` + `customEventStr` for a pixel
custom event. Becomes the ad set `promoted_object`; without it Meta
rejects the ad set ("Please select a promoted object", subcode 1815430).
With `adSetId` the existing ad set already carries it.

TikTok: BOTH `pixelId` and `customEventType` are required. TikTok refuses
a conversion ad group with no pixel ("Please select a pixel") and one
with a pixel but no event ("Select a pixel event."). Combine freely with
`platformPostId` + `sparkAuthCode`: the pixel lives on the ad group and
the Spark item on the creative.

Reddit: every ad group carries a pixel, whatever the goal. `pixelId` picks
it (default: the ad account's first pixel; 422 when the account has none).
`conversions` requires `customEventType` (PURCHASE, LEAD,
COMPLETE_REGISTRATION, SIGN_UP, ADD_TO_CART, PAGE_VISIT, VIEW_CONTENT or
CONTENT_VIEW), and the pixel must already have received that event (422
otherwise); `lead_generation` needs the pixel to have received Lead.

- **dsaBeneficiary** `string`: Legal entity that benefits from the ad. Required when targeting EU users
(EU DSA, Article 26). Optional if the ad account has a default beneficiary:
set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
fills it in whenever the field is omitted.

- **dsaPayor** `string`: Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
(for example, an agency paying for a client's ads). Same rules as
`dsaBeneficiary`: required for EU targeting unless the ad account has
a default payor.

- **leadGenFormId** `string`: Lead Gen form ID to attach to the boosted ad's creative. REQUIRED when `goal` is `lead_generation`. On Meta this is the leadgen_forms ID (create one via POST /v1/ads/lead-forms). On LinkedIn this is the adForm ID (create one via POST /v1/ads/lead-forms with a LinkedIn account); the creative's `leadgenCallToAction.destination` is set to `urn:li:adForm:{id}`. Ignored for other goals. Reddit: rejected with 400 on every goal (Reddit retired its native lead forms).
- **status** `string`: Publish state of the created entities, on every platform. Omitted or ACTIVE publishes live (default); PAUSED pauses only the top-most object this boost creates and switches everything below it on: a new campaign is held paused with its ad set and ad on (one PUT /v1/ads/campaigns/{campaignId}/status with `active` brings it live); into an existing campaign (TikTok `existingCampaignId`) the new ad set is held paused; attached to an existing ad set (`adSetId`) the new ad itself is paused. On LinkedIn the held campaign group is PAUSED, its campaign and creative ACTIVE. X has no per-ad switch, so its lowest level is the line item. `campaignStatus`, `adSetStatus` and `adStatus` set one level each and always win for that level; `status: PAUSED` adds a hold of its own only when none of them is PAUSED. To create every object paused, send all three as PAUSED. - one of: ACTIVE, PAUSED
- **campaignStatus** `string`: Every platform, same semantics as POST /v1/ads/create. Sets the switch of the new campaign alone (LinkedIn: the campaign group) and overrides `status` for it. `ACTIVE` with `status: PAUSED` switches the campaign on and holds the new ad set paused (its ad on). Omitted, it follows `status`. Rejected with a 400 alongside `adSetId` or `existingCampaignId`, where no campaign is created (change an existing one with PUT /v1/ads/campaigns/{campaignId}/status). - one of: ACTIVE, PAUSED
- **adSetStatus** `string`: Every platform, same semantics as POST /v1/ads/create. Sets the switch of the new ad set alone (Google, TikTok and Pinterest: the ad group; LinkedIn: the campaign, which stays DRAFT when held; X: the line item) and overrides `status` for it. Omitted, it follows `status`.

Precedence: a level status (`campaignStatus`, `adSetStatus`, `adStatus`) always wins for its level. `status: PAUSED` then holds the top-most new object that has no level status, and only when no level status is PAUSED; every other new object is switched on. So `campaignStatus: ACTIVE` + `adSetStatus: PAUSED` + `adStatus: PAUSED` keeps the campaign on with the new ad set and ad off, and all three PAUSED create the whole tree paused.

Rejected with a 400 alongside `adSetId` (that ad set already exists; change it with PUT /v1/ads/ad-sets/{adSetId}/status).
 - one of: ACTIVE, PAUSED
- **adStatus**: Sets the switch of the new ad alone, also when attaching to an existing ad set with `adSetId` (Meta, TikTok Smart+), and overrides `status` for it. Same precedence as `adSetStatus`. Omitted, it follows `status`. LinkedIn: a creative held off is created DRAFT, because LinkedIn refuses PAUSED on a creative until its review is APPROVED. A DRAFT creative is not submitted for review and cannot serve; it reads `paused`, and resuming the ad (PUT /v1/ads/{adId}/status `active`) switches it ACTIVE, which starts the review.

X returns a 400: a promoted post has no switch of its own, so hold the line item with `adSetStatus`.

- **budgetLevel** `string`: Meta only, same semantics as POST /v1/ads/create: campaign = Advantage campaign budget (CBO), the budget and bid strategy sit on the campaign and the ad set inherits them. Default adset. Not allowed with adSetId. - one of: adset, campaign
- **attributionSpec** `array`: Meta only. Ad-set attribution windows, same shape as POST /v1/ads/create. Applied on OUTCOME_SALES, OUTCOME_LEADS and OUTCOME_APP_PROMOTION campaigns (conversions, lead_conversion, lead_generation, app_promotion); other objectives keep Meta's default. Not allowed with adSetId.
- **bodies** `array`: Meta only. Extra primary-text options Meta rotates on the boosted post (asset_feed_spec.bodies with DEGREES_OF_FREEDOM); the post keeps its own text as one of the options. Works for Facebook posts and Instagram media. Under a conversions or traffic goal Meta also wants a website URL on the options, taken from `linkUrl` (send it with a `callToAction`); engagement boosts need none.
- **smartTargeting** `object`: TikTok only (a 400 elsewhere). TikTok Smart Targeting on the ad group: `audience` is
TikTok's `smart_audience_enabled`, `interestsBehaviors` its `smart_interest_behavior_enabled`.
When on, TikTok may deliver beyond the selected audiences or interests. Available on
Video views, Traffic, Lead generation, App install, Web conversion and Community interaction.
Only the flags you send are written; an unwritten flag reads back null in
`nativeSettings`, so send `false` explicitly to be able to verify it is off.
Not available with smartPlus or when attaching to an existing ad set (adSetId).

- **optimizationGoal** `string`: Meta, or TikTok with `goal: video_views`. TikTok: ENGAGED_VIEW (6-second
Focused View, the default) or ENGAGED_VIEW_FIFTEEN (15-second views), both
billed per view (CPV); any other value is a 400. Meta: explicit ad-set
`optimization_goal` override. When omitted,
defaults to the value derived from `goal`. Messaging boosts always
use CONVERSATIONS and reject another optimizationGoal. Otherwise the value must be compatible
with the objective Meta derives from `goal`, not with the objective used
by `POST /v1/ads/create` for the same `goal` name: boost maps `goal:
"engagement"` to objective `OUTCOME_AWARENESS`, which accepts
`REACH`, `IMPRESSIONS`, `AD_RECALL_LIFT`, or THRUPLAY-class values, and
rejects `POST_ENGAGEMENT` (that value is only valid under
`OUTCOME_ENGAGEMENT`, which create uses for the same goal name).


### Responses

#### 201: Ad created

**Response Body:**

- **ad**: `Ad` - See schema definition
- **message** `string`: No description

#### 400: Missing required fields or invalid values

#### 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 (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent, and `privateReplyOutcome: "unknown"` on its 502 when Meta
answered a server error and the reply may have been delivered.

On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.

A Zernio Google Ads burst-limit 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google, and both clear within a minute. `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 are separate axes from `quotaScope`, not the same pool named twice.

A failed Meta ad create (`POST /v1/ads/create`, `POST /v1/ads/boost`,
`POST /v1/ads/ctwa`) carries `stage`, `adAccountId` and `createdObjects`: where
it failed, on which ad account, and 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), so 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.

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 (create, boost, CTWA, campaign / ad set / ad updates and status
changes) answers 422 `ad_account_unusable`. 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`.

  - **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), or `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 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account. - 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: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans. Also returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume. Also returned with code `ad_account_not_selected` when the connection has selected ad accounts and the ad account this request targets is not among them; `details` carries `accountId`, `adAccountId` and `selectedAdAccountIds`. Add the ad account to the selection (GET /v1/connect/{platform}/ads with adAccountIds, which replaces the selection) or use a connection that has it selected.


#### 404: The account or requested resource was not found or is not accessible. An account ID may have been disconnected and removed. Read GET /v1/accounts for current account IDs.

**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 (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent, and `privateReplyOutcome: "unknown"` on its 502 when Meta
answered a server error and the reply may have been delivered.

On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.

A Zernio Google Ads burst-limit 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google, and both clear within a minute. `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 are separate axes from `quotaScope`, not the same pool named twice.

A failed Meta ad create (`POST /v1/ads/create`, `POST /v1/ads/boost`,
`POST /v1/ads/ctwa`) carries `stage`, `adAccountId` and `createdObjects`: where
it failed, on which ad account, and 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), so 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.

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 (create, boost, CTWA, campaign / ad set / ad updates and status
changes) answers 422 `ad_account_unusable`. 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`.

  - **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), or `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 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account. - 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

#### 409: The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying.
An identical boost request is already in progress (with or without
an Idempotency-Key). Wait for it to finish instead of retrying.


#### 422: Platform ads connection required (TikTok Ads, X Ads), missing linked
account, or (for TikTok) the connected TikTok user is not authorized
as an Identity on the target advertiser. Returned with code
`ads_connection_required`; the message includes the actionable
"TikTok Ads Manager → Assets → Identity" remediation step.
Also returned as `idempotency_key_reused` when an Idempotency-Key
is reused with a different request body, and as `ad_account_unusable`
when Meta refuses writes on the ad account because its status is
ineligible to manage ads (see `ErrorResponse.details`).


#### 429: Meta only (code `rate_limited`). Meta places a security hold lasting
days (code 31, subcode 3858385, "Please authenticate your account") on
ad accounts that receive bursts of ad writes, and it counts
`validateOnly` checks as writes. To keep integrations out of that hold,
Zernio runs creates for one Meta ad account one at a time (a parallel
request waits up to 60 seconds for its turn) and allows at most 30
creates per ad account in any rolling 5 minutes, `validateOnly`
included. A 429 here means one of the two was hit: wait `Retry-After`
seconds and send creates for that ad account sequentially. Other ad
accounts are not affected.


**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 (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent, and `privateReplyOutcome: "unknown"` on its 502 when Meta
answered a server error and the reply may have been delivered.

On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.

A Zernio Google Ads burst-limit 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google, and both clear within a minute. `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 are separate axes from `quotaScope`, not the same pool named twice.

A failed Meta ad create (`POST /v1/ads/create`, `POST /v1/ads/boost`,
`POST /v1/ads/ctwa`) carries `stage`, `adAccountId` and `createdObjects`: where
it failed, on which ad account, and 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), so 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.

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 (create, boost, CTWA, campaign / ad set / ad updates and status
changes) answers 422 `ad_account_unusable`. 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`.

  - **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), or `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 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account. - 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

#### 503: An upstream service or database is temporarily unavailable. Retry after the indicated delay. A timed-out write may have completed upstream; check its outcome before resubmitting.

**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 (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent, and `privateReplyOutcome: "unknown"` on its 502 when Meta
answered a server error and the reply may have been delivered.

On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.

A Zernio Google Ads burst-limit 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google, and both clear within a minute. `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 are separate axes from `quotaScope`, not the same pool named twice.

A failed Meta ad create (`POST /v1/ads/create`, `POST /v1/ads/boost`,
`POST /v1/ads/ctwa`) carries `stage`, `adAccountId` and `createdObjects`: where
it failed, on which ad account, and 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), so 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.

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 (create, boost, CTWA, campaign / ad set / ad updates and status
changes) answers 422 `ad_account_unusable`. 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`.

  - **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), or `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 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account. - 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

---


# Related Schema Definitions

## LinkedInAdsPlatformData

LinkedIn-specific options for POST /v1/ads/boost and POST /v1/ads/create: campaign bidding and delivery controls, plus the LinkedIn-only creative formats on /v1/ads/create. Unknown keys are rejected.


### Properties

- **costType** `string`: Campaign cost model (billing event). Defaults to `CPM`. Required when
`unitCost` is set so the manual bid applies to an explicit cost model.
 - one of: CPM, CPC, CPV
- **unitCost** `number`: Manual bid in WHOLE account-currency units (e.g. 2.5 = $2.50). Requires
`costType`. Omit for LinkedIn's automated (max delivery) bidding.
LinkedIn enforces its own per-audience min/max bid bounds.

- **optimizationTargetType** `string`: Campaign `optimizationTargetType` (e.g. `MAX_CLICK`, `TARGET_COST_PER_CLICK`,
`MAX_IMPRESSION`). Forwarded verbatim, LinkedIn validates compatibility with
the objective and `costType`. Omit for the objective-derived default:
`awareness` gets `MAX_IMPRESSION`, `video_views` gets `MAX_VIDEO_VIEW`, and
every other goal gets `MAX_CLICK`. `lead_generation` and `conversions` also
get `MAX_CLICK`, because `MAX_LEAD` and `MAX_CONVERSION` need a lead gen form
or a conversion rule that neither creation flow attaches. The default applies
only to `SPONSORED_UPDATES` campaigns (every boost, and the image, video and
carousel standalone ads), never to the `TEXT_AD`, `DYNAMIC` and
`SPONSORED_INMAILS` campaigns the other creative formats produce. It is also
skipped when `unitCost` or a non-`CPM` `costType` is set, since those select
manual bidding and the bid is then yours to choose.

- **creativeSelection** `string`: How LinkedIn rotates creatives within the campaign. Defaults to `OPTIMIZED`. - one of: OPTIMIZED, ROUND_ROBIN
- **audienceExpansionEnabled** `boolean`: Enable LinkedIn audience expansion. Defaults to false.
- **offsiteDeliveryEnabled** `boolean`: Deliver on the LinkedIn Audience Network. Defaults to false.
- **connectedTelevisionOnly** `boolean`: Restrict delivery to Connected TV inventory.
- **carousel** `object`: POST /v1/ads/create only. Carousel ad with 2-10 image cards.
Mutually exclusive with the other creative sources.

  - **cards** `array`: 
- **document** `object`: POST /v1/ads/create only. Document ad rendered as an in-feed viewer.
PDF, PPT or DOC up to 100MB. Mutually exclusive with the other
creative sources.

  - **url** `string`: 
  - **title** `string`: Document title.
- **spotlight** `object`: POST /v1/ads/create only. Dynamic Spotlight Ad personalized with the
viewer's profile photo. Supported goals: traffic, awareness. logoUrl
and organizationName default to the Company Page's; set them
explicitly if LinkedIn rejects the create with a 404. Mutually
exclusive with the other creative sources.

  - **headline** `string`: 
  - **description** `string`: Mutually exclusive with backgroundImageUrl.
  - **callToAction** `string`: Button label text.
  - **landingUrl** `string`: 
  - **logoUrl** `string`: 
  - **organizationName** `string`: 
  - **showMemberProfilePhoto** `boolean`: Defaults to true.
  - **backgroundImageUrl** `string`: Custom background. Replaces the description and the profile photo.
- **follower** `object`: POST /v1/ads/create only. Dynamic Follower Ad promoting the Company
Page. Supported goals: engagement, awareness. headline and
description take exactly one of preApproved or custom. Mutually
exclusive with the other creative sources.

  - **headline** `object`: 
  - **description** `object`: 
  - **callToAction** `string`:  - one of: VISIT_ORGANIZATION_COMPANY_PAGE, VISIT_ORGANIZATION_LIFE_PAGE, VISIT_ORGANIZATION_JOBS_PAGE, VISIT_ORGANIZATION_CAREERS_PAGE
  - **logoUrl** `string`: 
  - **organizationName** `string`: 
  - **showMemberProfilePhoto** `boolean`: Defaults to true.
- **jobs** `object`: POST /v1/ads/create only. Dynamic Jobs Ad promoting your open roles,
personalized with the viewer's profile photo. Requires goal
job_applicants and a Company Page with active job postings.
headline and buttonLabel take exactly one of
preApproved or custom. logoUrl and organizationName default to the
Company Page's. Mutually exclusive with the other creative sources.

  - **headline** `object`: 
  - **buttonLabel** `object`: 
  - **logoUrl** `string`: 
  - **organizationName** `string`: 
  - **showMemberProfilePhoto** `boolean`: Defaults to true.
- **textAd** `object`: POST /v1/ads/create only. Classic right-rail Text Ad. The copy lives
here; ad-level body and headline are not used. Mutually exclusive
with the other creative sources.

  - **headline** `string`: 
  - **description** `string`: 
  - **landingUrl** `string`: 
  - **imageUrl** `string`: Optional 100x100 image.
- **conversation** `object`: POST /v1/ads/create only. Conversation Ad: a choose-your-path message
tree delivered to the member's LinkedIn inbox. Messages are flat
nodes wired by local ids; each button either opens a url or leads to
nextMessageId. Cycles, unknown ids and a missing firstMessageId
return a 400. LinkedIn does not deliver message ads to EU members.
Mutually exclusive with the other creative sources.

  - **subject** `string`: InMail subject shown in the inbox.
  - **sender** `string`: Person or organization URN. Defaults to the authoring Company
Page. The sender must be approved for the ad account first
(Campaign Manager > Manage message ad senders) or LinkedIn
rejects the create with SINMAIL_SENDER_NOT_APPROVED.

  - **body** `string`: Optional intro body (HTML allowed).
  - **footer** `string`: Terms shown at the bottom of the message.
  - **headline** `string`: Conversation headline. Defaults to the first message's first line.
  - **firstMessageId** `string`: 
  - **messages** `array`: 
- **event** `object`: POST /v1/ads/create only. Promotes an existing LinkedIn Event; no
headline needed. Mutually exclusive with the other creative sources.

  - **urn** `string`: LinkedIn Event URN, urn:li:event:N.
- **thoughtLeader** `object`: POST /v1/ads/create only. Sponsors an existing LinkedIn post
(a share or ugcPost authored by your organization's Company
Page) as the creative, keeping its commentary, author and
engagement. Unlike boostPost, which provisions its own
CampaignGroup + Campaign around the post, this variant
attaches the reference under the campaign /v1/ads/create
builds, the same shape as every other format, so the caller can
pick bidding / targeting / schedule freely. No headline, body,
imageUrl or organization are needed; the referenced post
carries its own commentary and author. Mutually exclusive
with the other creative sources. Posts from personal profiles
(Thought Leader Ads) are NOT supported (see postUrn).

  - **postUrn** `string`: LinkedIn share or ugcPost URN, urn:li:share:N or urn:li:ugcPost:N. Get it via "Copy link to post" on the target LinkedIn post (the URL contains -share- for a share or -ugcPost- for a ugcPost, then the numeric id). The post must be authored by an organization (Company Page). Member (personal profile) posts, i.e. Thought Leader Ads proper, are rejected by LinkedIn's public Marketing API regardless of sponsorship approval and of post type (a LinkedIn limitation; their Campaign Manager creates those through a private API). Referencing a member post returns a 422 with a clear error.


## MetaAdsPlatformData

Meta (facebook/instagram) options for platformSpecificData on POST /v1/ads/boost and /v1/ads/create. Unknown keys are rejected, not dropped.

### Properties

- **bidStrategy**: No description
- **bidAmount** `number`: Whole currency units (USD: 5 = $5.00). Required when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. May also be sent alone, WITHOUT bidStrategy, to set the cap on an ad set joining a COST_CAP / LOWEST_COST_WITH_BID_CAP campaign (the strategy is inherited from the campaign). On POST /v1/ads/create that shape requires existingCampaignId and is a 400 otherwise; on POST /v1/ads/boost it is promoted to LOWEST_COST_WITH_BID_CAP.
- **roasAverageFloor** `number`: Decimal ROAS multiplier (2.0 = 2.0x). Required when bidStrategy is LOWEST_COST_WITH_MIN_ROAS; sending it without bidStrategy is a 400.
- **dailyMinSpendTarget** `number`: Meta daily_min_spend_target on the ad set being created: the least it should spend per day, in whole currency units. It reserves a share of a CAMPAIGN budget, so it requires budgetLevel campaign or an existingCampaignId whose campaign has the budget (Advantage campaign budget / CBO); with an ad-set budget it is a 400, because Meta rejects a spend limit on an ad set that owns its budget. A target, not a guarantee. Mutually exclusive with lifetimeMinSpendTarget: the flavour must match the campaign budget type. Rejected with 400 on POST /v1/ads/boost and in adSetId attach mode: use PUT /v1/ads/ad-sets/{adSetId} for an ad set that already exists.
- **lifetimeMinSpendTarget** `number`: Meta lifetime_min_spend_target: the lifetime-budget flavour of dailyMinSpendTarget, in whole currency units. Same rules and same rejections.
- **customerLifecycle**: No description

## Ad

### Properties

- **_id** `string`: No description
- **name** `string`: No description
- **platform** `string`: No description - one of: facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai, reddit
- **profileId** `string`: Profile the row belongs to: the profile of its owning connection (`accountId`); when that connection no longer exists, the requested profile (`profileId` filter) that reaches the row ad account, else the profile of the oldest connection of the row owner that reaches it.
- **status**: Delivery status. Derived from the platform `effective_status`, so it inherits ancestor pauses (an ACTIVE ad under a PAUSED campaign reads `paused`). For the ad's own on/off toggle use `configuredStatus`; for the review state use `reviewStatus`.
- **configuredStatus** `string,null`: The ad's own on/off switch as configured on the platform, independent of its parents: an ACTIVE ad under a paused ad set or campaign still reads ACTIVE here while `status` reads `paused`. Sources: Meta `configured_status`, TikTok ad `operation_status` (ENABLE -> ACTIVE, DISABLE -> PAUSED), ChatGPT (OpenAI) ad `status` (ACTIVE / PAUSED, plus ARCHIVED on OpenAI), Reddit `configured_status` (ACTIVE / PAUSED / ARCHIVED). On create it is read back from the platform, not copied from the request: a `PAUSED` create pauses only the top-most object it creates, so an ad under a new paused campaign or ad set reads ACTIVE here with `status: paused`, and only an ad attached to an existing ad set reads PAUSED. Distinct from `status`, which is the ancestor-cascaded delivery status. Only present for Meta, TikTok, OpenAI and Reddit ads created or synced after the field was added for that platform; null otherwise.
- **reviewStatus**: Platform review state of this ad, independent of delivery `status` / `configuredStatus`. Absent when the platform reports no review signal.
- **adType** `string`: No description - one of: boost, standalone
- **creativeType** `string,null`: Creative format, classified from the media the creative carries. `null` when the creative carries no media to classify. An unsynced creative and a genuine text-only ad are indistinguishable, so neither is guessed at. Returned by `GET /v1/ads`, `GET /v1/ads/{adId}` and the ad nodes of `GET /v1/ads/tree`. - one of: carousel, video, document, image, 
- **goal** `string`: Available goals vary by platform. Meta (Facebook/Instagram) supports all 11 (incl. `lead_conversion` = website pixel lead optimization, `catalog_sales` = Advantage+ catalog ads `page_likes` = Page Likes conversion location under Engagement and `page_visits` = Page visits and followers under Traffic). TikTok supports engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion. LinkedIn supports all Meta goals except app_promotion / lead_conversion / catalog_sales / page_likes / page_visits. X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest supports only engagement, traffic, awareness, video_views. Google Ads supports only engagement, traffic, awareness (video_views is rejected at create with 422 FEATURE_NOT_AVAILABLE). - one of: engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales, page_likes, page_visits, job_applicants
- **isExternal** `boolean`: True for ads synced from platform ad managers
- **budget** `object,null`: No description
- **metrics**: No description
- **platformAdId** `string`: No description
- **platformAdAccountId** `string`: No description
- **platformCampaignId** `string`: No description
- **platformAdSetId** `string`: No description
- **campaignName** `string`: No description
- **adSetName** `string`: No description
- **platformObjective** `string,null`: The platform's own campaign objective, verbatim, alongside the normalized `goal` it maps to.
The mapping is many-to-one, so `goal` alone cannot be mapped back to it.

- Meta: campaign `objective` (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC).
- LinkedIn: campaign `objectiveType` (e.g. WEBSITE_VISIT, LEAD_GENERATION, BRAND_AWARENESS,
  VIDEO_VIEW, ENGAGEMENT, JOB_APPLICANT, WEBSITE_CONVERSION). The list is open, so treat an
  unrecognized value as valid rather than an error.
- TikTok and Pinterest: their raw objective_type.

Null on platforms that report none, and on LinkedIn ads not yet re-synced.

- **optimizationGoal** `string,null`: What the delivery system optimizes for, at ad-set level. The value space depends on `platform`:

- Meta: ad set `optimization_goal` (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS).
- LinkedIn: the campaign's EFFECTIVE `optimizationTargetType`, refreshed from LinkedIn on every
  sync rather than echoing what was passed on create. `NONE` means manual bidding, and it is a
  real value, not missing data. Auto-bid values are MAX_IMPRESSION / MAX_CLICK / MAX_CONVERSION /
  MAX_VIDEO_VIEW / MAX_LEAD / MAX_REACH; target-cost values are TARGET_COST_PER_CLICK /
  TARGET_COST_PER_IMPRESSION / TARGET_COST_PER_VIDEO_VIEW; cost-cap values are the
  CAP_COST_AND_MAXIMIZE_* family.

- **costType** `string,null`: LinkedIn only. The campaign's EFFECTIVE cost model (billing event) as applied by LinkedIn,
refreshed on every sync rather than echoing what was passed on create. One of `CPM` (cost per
thousand impressions), `CPC` (cost per click) or `CPV` (cost per video view). On LinkedIn this is
the axis that pairs with `bidAmount`; there is no `bidStrategy`. For campaign type
SPONSORED_INMAILS, `CPM` bills as cost-per-send x 1000. `null` for non-LinkedIn ads.

- **servingStatuses** `array`: LinkedIn only. Why the parent campaign is (or is not) delivering, verbatim from LinkedIn.
A campaign can report `status: ACTIVE` and still serve nothing; this array is what says so.

- `[]` means no serving data: a non-LinkedIn ad, or a LinkedIn ad not yet re-synced.
- `["RUNNABLE"]` means the campaign is eligible to serve.
- Anything else is a hold. Known values include ACCOUNT_SERVING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD,
  ACCOUNT_END_DATE_HOLD, CAMPAIGN_START_DATE_HOLD, CAMPAIGN_END_DATE_HOLD,
  CAMPAIGN_TOTAL_BUDGET_HOLD, CAMPAIGN_AUDIENCE_COUNT_HOLD, CAMPAIGN_GROUP_START_DATE_HOLD,
  CAMPAIGN_GROUP_END_DATE_HOLD, CAMPAIGN_GROUP_TOTAL_BUDGET_HOLD, CAMPAIGN_GROUP_STATUS_HOLD and
  STOPPED. The list is open on purpose, so treat unrecognized values as holds rather than errors.

The end-date and total-budget holds are terminal and surface as `status: completed`; the rest
surface as `status: paused`. A hold is not the only cause of zero delivery: with
manual, target-cost or cost-cap bidding, a `bidAmount` of 0 stops delivery while
`servingStatuses` still reads `["RUNNABLE"]`. Check `costType` / `bidAmount` /
`optimizationGoal` as well.

- **platformAdAccountName** `string,null`: Human-readable advertiser/account name (Meta `AdAccount.name`, TikTok
`advertiser_name`, LinkedIn / X / Pinterest equivalents). Refreshed every
sync so platform-side renames propagate within one cycle. `null` when the
platform doesn't return a name or the sync hasn't run yet.

- **platformCreatedAt** `string,null`: Platform-reported creation timestamp (Meta `created_time`, TikTok `create_time`).
Distinct from `createdAt` which reflects when Zernio first synced the doc. To
sort or filter by "when the ad was actually created on the platform", read this field.
`null` for legacy ads synced before this field was added; aggregations fall back
to `createdAt` in that case.

- **bidStrategy**: Ad-set bid strategy (overrides campaign level on Meta). Populated for Meta and
TikTok. TikTok's native `bid_type` is normalized to the cross-platform Meta enum:
`BID_TYPE_NO_BID` -> `LOWEST_COST_WITHOUT_CAP`, `BID_TYPE_CUSTOM` ->
`LOWEST_COST_WITH_BID_CAP`, deep_bid_type=MIN_ROAS or roas_bid>0 ->
`LOWEST_COST_WITH_MIN_ROAS`, `BID_TYPE_MAX_CONVERSION` -> `LOWEST_COST_WITHOUT_CAP`.

- **bidAmount** `number,null`: Bid amount in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).

- Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here). Populated
  when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; `null` for auto-bid
  (`LOWEST_COST_WITHOUT_CAP`).
- TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`
  (whichever is set on the ad group). TikTok stores all three in whole currency units.
- LinkedIn source: the campaign's EFFECTIVE `unitCost`, refreshed on every sync rather than
  echoing what was passed on create. Its meaning depends on the bidding mode implied by
  `optimizationGoal`: bid amount (manual), target cost, or cost cap. It pairs with `costType`,
  NOT with `bidStrategy`, which LinkedIn does not have. A value of `0` is a real, delivery-
  stopping configuration and not "unset", so do not gate this field on `bidStrategy` for
  LinkedIn ads.

Source: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).

- **roasAverageFloor** `number,null`: Minimum ROAS as a decimal multiplier (2.0 = 2.0x ROAS). Populated when bidStrategy
is `LOWEST_COST_WITH_MIN_ROAS`.

- Meta source: decoded from `bid_constraints.roas_average_floor` (Meta stores as
  fixed-point int × 10000; we return the decimal).
- TikTok source: `roas_bid` on the ad group (already a decimal).

Source: facebook-business-sdk-codegen api_specs/specs/AdCampaignBidConstraint.json.

- **promotedObject** `object,null`: Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads.
- **creative** `object,null`: Platform-specific creative data. Fields vary by platform.
- **targeting** `object`: The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions).
For ads created through Zernio this is the spec you supplied. For external ads (synced from
Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest,
so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad;
the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`),
the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails.

- **schedule** `object,null`: Delivery window as UTC instants. For ads created through Zernio this is the start and end the platform stored (on TikTok, read back from the ad group, whose schedule times TikTok keeps in UTC); `startDate` is the creation time only when no start was requested.
- **rejectionReason** `string`: No description
- **createdAt** `string`: No description
- **updatedAt** `string`: No description

## 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 (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent, and `privateReplyOutcome: "unknown"` on its 502 when Meta
answered a server error and the reply may have been delivered.

On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.

A Zernio Google Ads burst-limit 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google, and both clear within a minute. `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 are separate axes from `quotaScope`, not the same pool named twice.

A failed Meta ad create (`POST /v1/ads/create`, `POST /v1/ads/boost`,
`POST /v1/ads/ctwa`) carries `stage`, `adAccountId` and `createdObjects`: where
it failed, on which ad account, and 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), so 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.

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 (create, boost, CTWA, campaign / ad set / ad updates and status
changes) answers 422 `ad_account_unusable`. 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`.

  - **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), or `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 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit.
  - **quotaScope** `string`: Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account. - 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