---
title: "Update ad"
method: PUT
path: "/v1/ads/{adId}"
tags: ["Ad Campaigns"]
---

# Update ad

`PUT /v1/ads/{adId}`

Patch one or more fields on an ad. Status, budget, targeting, and creative changes
are propagated to the platform.

Per-platform support:
- **Meta** (Facebook + Instagram): all fields supported.
- **TikTok**: status, budget, `name` (renames the ad), targeting (via `/v2/adgroup/update/`), and creative
  (via `/v2/ad/update/` patch-style: `headline` is ignored, `body` becomes `ad_text`).
- **Google**: status, budget, KEYWORD edits via `targeting.keywords` /
  `targeting.negativeKeywords`, DEVICE bid adjustments via `targeting.devices`,
  LOCATION edits via `targeting.locations` (or the equivalent top-level
  `targeting.countries` / `regions` / `cities` / `zips` / `metros`), and LANGUAGE
  edits via `targeting.languages`.
  Each list you send becomes the FULL new set of its kind (criteria not in the
  list are removed, except devices, which Google cannot remove and which are
  switched off with a bid modifier of 0 instead); a kind left out is untouched.
  Any other `targeting` field
  returns 400: Google cannot mutate it post-create without recreating
  the campaign. Creative edits are dispatched on the ad's `advertisingChannelType`,
  and every supported field replaces a whole set; a field you omit is preserved.
  - **Search**: top-level `headlines`, `descriptions` and `finalUrls`. Use 3-15 headlines
    (1-30 characters) and 2-4 descriptions (1-90 characters). Omit an asset to remove it;
    omit pinnedField on an included asset to unpin it. Updates do not pad or truncate text.
    The legacy creative fields remain unsupported.
  - **Display**: top-level `headlines` (1-5, no pinnedField, display ads have no pinned
    positions), `descriptions` (1-5) and `finalUrls`, plus `creative.longHeadline`,
    `creative.businessName`, `creative.imageUrl` (the landscape marketing image) and
    `creative.squareImageUrl`. Each image URL is uploaded as a new Google asset and the ad
    is pointed at it; Google assets are immutable, so the previous asset stays in the
    account's asset library.
  - **Performance Max**: top-level `assetGroup`, which swaps asset roles on the ad's asset
    group. The other creative fields return 422 for this channel, and `assetGroup` returns
    422 on any other channel.
- **LinkedIn**: status, budget, targeting (countries or regions, excludedLocations (countries),
  the B2B facets, and audience segments; applied to the LinkedIn Campaign via
  PARTIAL_UPDATE, and REPLACES the campaign's entire targetingCriteria, not a merge),
  and creative (uploads new media, creates a replacement inline creative on the same
  campaign, pauses the old one).
- **Pinterest / X / OpenAI Ads**: status + budget only. Sending
  `targeting` or `creative` returns 501 with code `unsupported_platform_operation`.
  OpenAI Ads budget is lifetime-only (see `budget.type` below).

**Google location and language replacement:** locations, languages and devices are
campaign-level criteria on Google, so these edits apply to every ad group and ad in
the ad's campaign. Send the complete list you want to keep. Zernio diffs it against
the campaign's live criteria and sends the removes and the creates in ONE
`googleAds:mutate`, so the campaign is never left with a half-applied set; criteria
already in the list keep their criterion ID and history. Excluded (negative)
locations are left untouched. An empty location list returns 400 (a Google campaign
with no location criteria targets every country, which is never what "remove my
locations" means, so omit the field instead). Send either `targeting.locations` or the
top-level geo fields, not both: mixing them returns 400.

**Google radius targeting:** `customLocations` is editable and is replaced the same
way, but as its OWN set. Google models a place (LOCATION) and a point plus radius
(PROXIMITY) as different criterion types, so the two are independent: sending
`customLocations` replaces every radius and leaves the cities and countries alone,
and sending places replaces those and leaves the radius alone. Send
`customLocations: []` to drop radius targeting entirely. A circle you re-send
unchanged keeps its criterion ID rather than being removed and recreated.

**Google keyword replacement:** These edits affect the ad's entire ad group,
including sibling ads. Positive (`targeting.keywords`) and negative
(`targeting.negativeKeywords`) sets are independent: omit a field to leave
that set unchanged, or send `[]` to remove every keyword of that kind.

Zernio compares each supplied set with Google's live criteria by
case-insensitive keyword text and match type. A matching criterion is left
untouched, retaining its criterion ID, enabled/paused status, keyword-level
bid overrides, labels, and criterion-associated history/statistics. Zernio
does not reset its quality score; Google continues to calculate scores and
statistics normally. Text comparison does not trim whitespace.

A bare string or an object without `matchType` means `broad`, not the
existing criterion's match type. For example, resending an existing
`{ "text": "plumber", "matchType": "exact" }` preserves it; sending
`"plumber"` instead removes that EXACT criterion and requests a BROAD one.
Changing text or match type removes criteria no longer requested and
creates any missing criteria. New criteria get new IDs and do not inherit
removed criteria's bid overrides, labels, or history. Historical reporting
for a removed criterion is not transferred to its replacement.

To add keywords without replacing a set, use
[POST /v1/ads/keywords](https://docs.zernio.com/ad-campaigns/add-ad-keywords).
Use `PATCH /v1/ads/keywords/{keywordId}` to pause/enable one keyword, or
`DELETE /v1/ads/keywords/{keywordId}` to remove it.

## Path parameters

- `adId` string, required

## Request body

- object
  - `headlines` GoogleRsaHeadline[] — Google Search and Display only. Replaces the complete headline list. Search takes 3-15, Display 1-5 and rejects pinnedField; the count is checked once the ad's channel is known. No padding or truncation on update.
    - `text` string, required
    - `pinnedField` 'HEADLINE_1' | 'HEADLINE_2' | 'HEADLINE_3' — Optional fixed headline position. Omit to leave the asset unpinned.
  - `descriptions` GoogleRsaDescription[] — Google Search and Display only. Replaces the complete description list. Search takes 2-4, Display 1-5 and rejects pinnedField. No padding or truncation on update.
    - `text` string, required
    - `pinnedField` 'DESCRIPTION_1' | 'DESCRIPTION_2' — Optional fixed description position. Omit to leave the asset unpinned.
  - `finalUrls` string[] — Google Search and Display only. Replaces final URLs. Omitted lists stay unchanged. For Performance Max use assetGroup.finalUrl.
  - `assetGroup` GooglePmaxAssetGroupUpdate — Replacement assets for an existing Performance Max asset group, sent on PUT /v1/ads/{adId}. Google assets are immutable (AssetService only creates), so each field you send becomes new assets linked to the asset group, and the assets that role held are unlinked in the same atomic request. Send one field or many; a field you omit is left untouched. Re-sending a value the asset group already carries is a no-op for that asset, not a re-upload. Unlinked assets stay in the account's asset library: Google has no asset delete. At least one description must be 60 characters or fewer. Texts within each list must be distinct.
    - `finalUrl` string, uri — Replaces the asset group's final URL.
    - `headlines` string[] — Replaces every HEADLINE asset on the group.
    - `longHeadline` string — Replaces the LONG_HEADLINE asset.
    - `descriptions` string[] — Replaces every DESCRIPTION asset. At least one must be 60 characters or fewer.
    - `businessName` string — Replaces the BUSINESS_NAME asset.
    - `images` object — Public HTTP(S) image URLs. Each role you send replaces that role's images; roles you omit stay. GIF, JPEG or PNG, at most 5120 KB per image.
      - `landscape` string[] — Replaces MARKETING_IMAGE assets. Aspect ratio 1.91:1, minimum 600 x 314 pixels.
      - `square` string[] — Replaces SQUARE_MARKETING_IMAGE assets. Aspect ratio 1:1, minimum 300 x 300 pixels.
      - `logo` string[] — Replaces LOGO assets. Aspect ratio 1:1, minimum 128 x 128 pixels.
    - `youtubeVideoIds` string[] — Replaces YOUTUBE_VIDEO assets with existing YouTube video ids. Video uploads and arbitrary video URLs are not supported.
  - `status` 'active' | 'paused'
  - `budget` object
    - `amount` number — Minimum varies by platform: TikTok=$20, Pinterest=$5, others=$1
    - `type` 'daily' | 'lifetime' — OpenAI Ads accepts lifetime only; sending daily returns 422.
  - `targeting` object — Meta + TikTok (demographics/interests), Google (keyword and device bid adjustment edits only), and LinkedIn (countries or regions required). Pinterest / X return 501.
    - `keywords` union[] — Google only. The FULL desired set of positive keywords for the entire ad group. Omit to leave positives unchanged; [] removes all positives. Negatives are independent. Entries are strings (BROAD) or { text, matchType } with matchType exact | phrase | broad; an omitted matchType also defaults to BROAD. Matching case-insensitive text AND match type retains the existing criterion ID, status, bid overrides, labels and history without a mutation. A changed text or match type uses remove/create, without transferring the old criterion's attributes or history. See Google keyword replacement above for an EXACT-to-BROAD example. Mirrored to GET /v1/ads/keywords immediately.
      - union
        - string
        - object
          - `text` string, required
          - `matchType` 'exact' | 'phrase' | 'broad'
    - `negativeKeywords` union[] — Google only. The FULL desired set of negative keywords for the entire ad group, independent of positives. Omit to leave negatives unchanged; [] removes all negatives. Uses the same text/match-type identity and preservation contract as keywords above. Strings and objects without matchType default to BROAD, so resending an EXACT or PHRASE negative as a bare string requests a different criterion. Campaign negatives are separate: use /v1/ads/campaigns/{campaignId}/negative-keywords to manage those.
      - union
        - string
        - object
          - `text` string, required
          - `matchType` 'exact' | 'phrase' | 'broad'
    - `devices` union[] — Google only. The FULL new set of device bid modifiers for the campaign. Entries are a device name alone (targeted, bid modifier reset to 1) or { device, bidModifier }. A supported device you leave out is switched off, written as a bid modifier of 0. Google never removes a device criterion, so an excluded device reads back as bidModifier 0 rather than disappearing, and a set that switches every device off returns 422. Which devices a campaign carries depends on its channel: Search campaigns have MOBILE, DESKTOP and TABLET, Display campaigns also have CONNECTED_TV, and sending a device the campaign does not carry returns 422.
      - union
        - 'MOBILE' | 'DESKTOP' | 'TABLET' | 'CONNECTED_TV'
        - object
          - `device` 'MOBILE' | 'DESKTOP' | 'TABLET' | 'CONNECTED_TV', required
          - `bidModifier` number — Google device bid modifier. 0 switches the device off (minus 100%); otherwise 0.1 to 10 (minus 90% to plus 900%). Google rejects any value between 0 and 0.1.
    - `ageMin` integer
    - `ageMax` integer
    - `countries` string[] — ISO 3166-1 alpha-2 codes. On Google this is the FULL new country set for the campaign (same contract as `locations`); on LinkedIn it replaces the campaign's geo criteria.
    - `locations` union — Google and LinkedIn. The FULL new location set for the campaign. Bare country-code array, or an object with countries/regions/cities/zips/metros key lists (`key` from GET /v1/ads/targeting/search?dimension=geo). Equivalent to the top-level geo fields; sending both returns 400. Empty returns 400, `customLocations` returns 422.
      - string[]
      - object
        - `countries` string[]
        - `regions` object[]
          - `key` string, required
          - `name` string
        - `cities` object[]
          - `key` string, required
          - `name` string
        - `zips` object[]
          - `key` string, required
          - `name` string
        - `metros` object[]
          - `key` string, required
          - `name` string
    - `languages` string[] — Google only. The FULL new language set for the campaign, as Google language codes (ISO 639-1, plus variants such as `zh_CN`). An unknown code returns 400.
    - `interests` object[] — Interest objects from /v1/ads/interests. Each must include id and name.
      - `id` string, required
      - `name` string, required
    - `advantage_audience` 0 | 1 — Meta only. Omit to preserve the existing setting on update. 0 = disabled, 1 = enabled.
  - `creative` object — Replace or patch the ad's creative. Meta, TikTok, and LinkedIn. - **Meta**: patch-style. Pass any subset: fields you omit are preserved from the live creative, including media (`image_hash`/`video_id` are reused, no re-upload) and `url_tags`. Sending the full set (`headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`) rebuilds the creative from scratch instead. Partial patching reads the live `object_story_spec`, which Meta strips on SHARE / page-post / dark / asset_feed creatives. Those return 422 asking for the full set. A `videoUrl`/`videoId` on an image creative is a type change and also needs the full set. `existingCreativeId` repoints the ad at a creative from GET /v1/ads/creatives and ignores every other field. Meta creatives are immutable, so any change creates a new creative and repoints the ad; the old creative is retained on the ad account for historical reporting. `creativeFeatures` is Meta-only. Omitted settings are preserved from the live creative, including full rebuilds. A supplied creativeFeatures map overrides individual existing keys. - **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes `landing_page_url`; `videoUrl` triggers a fresh upload. `description`, `videoId` and `existingCreativeId` are Meta-only and return 400. - **LinkedIn**: requires new media (image via `imageUrl` or video via `videoUrl`); a text-only creative update returns 400. Uploads the media, creates a new inline media creative on the same campaign, and pauses the old creative (best-effort). The old creative is retained for historical reporting. `videoId` and `existingCreativeId` are Meta-only and return 400.
    - `promotion` MetaPromotion, nullable — Not supported. Meta validates creative_sourcing_spec.promotion_metadata_spec on the create call and then discards it, so a Promotion set through the Marketing API never reaches the creative. Any object is rejected with 400 invalid_field_value. Send null or omit the field, and set the Promotion on the ad in Ads Manager. Verified on 2026-09-11 across Graph v19.0 to v25.0 and every write path.
    - `creativeFeatures` MetaCreativeFeatures — Meta Advantage+ creative enhancements. Map snake_case feature names to OPT_IN or OPT_OUT; Meta validates supported keys and unspecified features default to OPT_OUT. auto_promotion_tag is an Advantage+ enhancement, not the Ads Manager Promotion setting. The deprecated standard_enhancements bundle is rejected by Meta.
    - `headline` string — Meta and LinkedIn (TikTok has no headline slot)
    - `longHeadline` string — Google Display only. Replaces the responsive display ad's long headline.
    - `businessName` string — Google Display only. Replaces the responsive display ad's business name.
    - `squareImageUrl` string, uri — Google Display only. Uploaded as a new square (1:1) marketing image asset that replaces the current one.
    - `body` string
    - `description` string — Link description slot (Meta `link_data.description` / `video_data.link_description`, LinkedIn creative description).
    - `callToAction` string
    - `linkUrl` string, uri
    - `imageUrl` string, uri
    - `videoUrl` string, uri
    - `videoId` string — Meta only. Reuse an already-uploaded ad video (from POST /v1/ads/videos or GET /v1/ads/videos) instead of re-uploading via videoUrl.
    - `existingCreativeId` string — Meta only. Repoint the ad at an existing library creative (from GET /v1/ads/creatives); all other creative fields are ignored.
  - `name` string — Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501.

## Response `200`

Ad updated

- object
  - `ad` Ad
    - `_id` string
    - `name` string
    - `platform` 'facebook' | 'instagram' | 'tiktok' | 'linkedin' | 'pinterest' | 'google' | 'twitter' | 'openai'
    - `status` 'active' | 'paused' | 'pending_review' | 'rejected' | 'completed' | 'cancelled' | 'error'
    - `configuredStatus` string, nullable — The ad's own on/off toggle as configured on the platform (Meta `configured_status`: ACTIVE / PAUSED), unaffected by ancestor (ad set / campaign) pauses. Distinct from `status`, which is the ancestor-cascaded delivery status. Only present for Meta ads synced after this field was added.
    - `reviewStatus` 'in_review' | 'approved' | 'rejected' | 'with_issues' — Platform-side review state, independent of the delivery `status` and the `configuredStatus` on/off toggle. `in_review` means the platform is still reviewing. Absent when the platform reports no review signal (e.g. a paused ad whose review state is masked behind the pause).
    - `adType` 'boost' | 'standalone'
    - `creativeType` 'carousel' | 'video' | 'document' | 'image' | 'null', nullable — 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`.
    - `goal` 'engagement' | 'traffic' | 'awareness' | 'video_views' | 'lead_generation' | 'lead_conversion' | 'conversions' | 'app_promotion' | 'catalog_sales' | 'page_likes' | 'job_applicants' — Available goals vary by platform. Meta (Facebook/Instagram) supports all 10 (incl. `lead_conversion` = website pixel lead optimization, `catalog_sales` = Advantage+ catalog ads and `page_likes` = Page Likes conversion location under Engagement). 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. 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).
    - `isExternal` boolean — True for ads synced from platform ad managers
    - `budget` object, nullable
      - `amount` number
      - `type` 'daily' | 'lifetime'
    - `metrics` AdMetrics
      - `spend` number
      - `impressions` integer
      - `reach` integer — Unique people reached in the requested date range. Meta (facebook/instagram) and TikTok: the platform's own de-duplicated reach for the exact range, fetched live and cached up to ~1 hour (may lag recent delivery; on a transient platform error the value temporarily falls back to a sum of per-day reach, which overcounts people reached on multiple days or by multiple child ads). Because it is de-duplicated, reach is NOT additive on these platforms: neither daily values nor child nodes sum to the range total. Google, LinkedIn, X, Pinterest and OpenAI report 0 (reach not synced). Frequency (impressions / reach) is only meaningful for Meta and TikTok.
      - `clicks` integer
      - `ctr` number — Click-through rate (%)
      - `cpc` number — Cost per click
      - `cpm` number — Cost per 1000 impressions
      - `engagement` integer
      - `conversions` number — Count of conversion events over the requested date range. FRACTIONAL: attribution splits one conversion across touchpoints and Google additionally reports modeled conversions, so values like 0.347 are normal. Meta: events matching the campaign's promoted_object.custom_event_type (PURCHASE, LEAD, etc.). Google: the account's tracked conversions. X and LinkedIn: their reported website/lead conversions (added 2026-07). 0 for non-conversion campaigns or when no events have fired.
      - `allConversions` number — All conversions, including actions excluded from the Conversions column (Google metrics.all_conversions). 0 on platforms without the concept.
      - `costPerConversion` number — Derived spend / conversions in the same currency as spend. 0 when conversions is 0.
      - `actions` object — Per-action-type counts summed over the date range, keyed by the platform's action-type names. Meta: raw Insights action_type keys (link_click, offsite_conversion.fb_pixel_purchase, onsite_conversion.lead_grouped, ...), covering both engagement and conversion events. TikTok: pixel conversions (purchase, add_to_cart, initiate_checkout, view_content, complete_payment, lead) plus the paid-engagement family (follow, post_reaction for paid likes, comment, share). Follow is how FOLLOWERS-goal campaigns report their result. X: conversion types (purchase, sign_up, site_visit, download, custom). LinkedIn: conversion types (post_click, post_view, lead_gen). Google returns {} (its per-action names aren't synced per ad). Empty object when no actions are reported. NOTE: keys differ by platform, so branch on the ad's platform when interpreting them.
      - `actionValues` object — Monetary mirror of `actions`, from Meta's Insights `action_values[]` array. Same keying: values are the revenue attributed to each action_type, in ad-account native currency (same unit as `spend`; see the campaign node's `currency` field). Use this to compute revenue-per-event (e.g. avg purchase value). Meta-only; other platforms return {}.
      - `purchaseValue` number — Convenience sum of purchase-type action values, picked from `actionValues` via the same priority list as `conversions` so both fields describe the same events. In ad-account native currency. 0 when the campaign has no purchase event configured. Meta-only.
      - `roas` number — Return on ad spend, derived as `purchaseValue / spend`. 0 when `spend` is 0. Equivalent to Meta's `purchase_roas` under default attribution. At ad-set and campaign levels this is recomputed from summed purchaseValue + spend (NOT averaged across children) so it's mathematically correct at every rollup level.
      - `costPerAction` object — Derived `spend / actions[type]` for every action type with a non-zero count, in ad-account native currency. Same keys as `actions`. Rounded to 4 decimals because cheap actions cost well under a cent. Recomputed from summed spend + counts at every rollup level. Empty object when spend is 0 or no actions are reported.
      - `outboundClicks` integer — Clicks leading off Meta's surfaces to the advertiser's destination. Meta-only; other platforms report 0.
      - `outboundClicksCtr` number — Derived `outboundClicks / impressions * 100`, recomputed from sums at every rollup level.
      - `inlineLinkClicks` integer — In-session link clicks. Differs from the attributed `link_click` count in `actions`/`engagementBreakdown.linkClicks`, which uses the attribution window. Meta-only.
      - `inlineLinkClickCtr` number — Derived `inlineLinkClicks / impressions * 100`, recomputed from sums at every rollup level.
      - `uniqueClicks` integer — People who clicked at least once. NOT additive: summed across days/children it overcounts people who clicked on multiple days or ads, so treat rollups as an upper bound (same caveat as `reach`). Meta-only.
      - `uniqueCtr` number — Derived `uniqueClicks / impressions * 100` (NOT Meta's reach-based unique_ctr). Inherits the non-additivity caveat of `uniqueClicks`.
      - `videoPlayActions` integer — Number of times the video started playing, summed over the date range and across children at ad-set/campaign level. 0 for non-video ads. Sources: Meta `video_play_actions`, TikTok `video_play_actions`.
      - `video30SecWatchedActions` integer — Views of at least 30 seconds (or to the end, for shorter videos). Sources: Meta `video_30_sec_watched_actions` (Meta only).
      - `videoThruplayWatchedActions` integer — ThruPlays (watched to completion, or at least 15 seconds). Sources: Meta `video_thruplay_watched_actions` (Meta only).
      - `videoP25WatchedActions` integer — Views reaching 25% of the video's length. With the other percentile fields, powers hook/hold/drop-off analysis (e.g. hook rate = videoP25WatchedActions / videoPlayActions). Sources: Meta `video_p25_watched_actions`, TikTok `video_views_p25`.
      - `videoP50WatchedActions` integer — Views reaching 50% of the video's length. Sources: Meta `video_p50_watched_actions`, TikTok `video_views_p50`.
      - `videoP75WatchedActions` integer — Views reaching 75% of the video's length. Sources: Meta `video_p75_watched_actions`, TikTok `video_views_p75`.
      - `videoP95WatchedActions` integer — Views reaching 95% of the video's length. Sources: Meta `video_p95_watched_actions` (Meta only).
      - `videoP100WatchedActions` integer — Views reaching 100% of the video's length. Sources: Meta `video_p100_watched_actions`, TikTok `video_views_p100`.
      - `videoAvgTimeWatchedActions` number — Average seconds watched per play. Aggregated over date ranges and across children as a play-weighted average (total watch time / total plays), never a plain average of averages. Sources: Meta `video_avg_time_watched_actions`, TikTok `average_video_play`.
      - `costPerThruplay` number — Derived `spend / videoThruplayWatchedActions`, in ad-account native currency. Rounded to 4 decimals rather than the usual 2 because a ThruPlay routinely costs well under a cent. 0 when the ad has no ThruPlays (ThruPlay is Meta-only).
      - `funnel` AdFunnelCounts — Named conversion-funnel steps, resolved from the same data as `actions` so you never have to parse action-type strings yourself. Meta reports one event under several action types at once (`offsite_conversion.fb_pixel_purchase`, `omni_purchase`, `purchase`, …). Each field below takes the FIRST family member present rather than summing them, which is what makes these counts safe to add up. Summing the raw `actions` keys yourself double or triple counts. The same priority order backs `conversions`, so a purchase-optimised campaign reports the identical number in `conversions` and `funnel.purchases`. Every field is 0 when that step never fired. Populated for Meta ads; other platforms report a different action taxonomy and generally leave these at 0 (read `actions` for those). At ad-set and campaign level each step is summed from its per-ad values.
        - `landingPageViews` integer — Landing page views: the visitor actually loaded the destination, unlike a link click. Meta `landing_page_view`.
        - `contentViews` integer — Content views (Meta `ViewContent` pixel event).
        - `searches` integer — On-site searches (Meta `Search` pixel event).
        - `wishlistAdds` integer — Adds to wishlist (Meta `AddToWishlist` pixel event).
        - `cartAdds` integer — Adds to cart (Meta `AddToCart` pixel event).
        - `checkoutsInitiated` integer — Checkouts started (Meta `InitiateCheckout` pixel event).
        - `paymentInfoAdds` integer — Payment details added at checkout (Meta `AddPaymentInfo` pixel event).
        - `purchases` integer — Purchases (Meta `Purchase` pixel event). Pair with `purchaseValue` for revenue.
        - `leads` integer — Leads, from either the website pixel or an instant form, whichever the ad uses.
        - `registrationsCompleted` integer — Completed registrations (Meta `CompleteRegistration` pixel event).
        - `appInstalls` integer — Mobile app installs attributed to the ad.
        - `messagingConversationsStarted` integer — Messaging conversations started within 7 days, the headline metric for click-to-WhatsApp and click-to-Messenger ads.
        - `messagingFirstReplies` integer — Messaging threads where the person sent a first reply.
      - `engagementBreakdown` AdEngagementCounts — The single `engagement` total split into the interactions behind it. `engagement` is not the sum of these: Meta's own `post_engagement` and `page_engagement` totals already contain the individual interactions, and all of them are counted into `engagement`. Use these fields when you need a specific interaction, and `engagement` only as the coarse total it has always been. Populated for Meta and, since 2026-08, TikTok (`reactions` = paid likes, `comments`, `shares`; TikTok's `follow` count lives in `actions.follow`, not here). Other platforms leave these at 0. TikTok history note: paused TikTok ads are not re-synced, so campaigns that ended before the rollout keep 0s here.
        - `postEngagement` integer — Meta's own post-engagement total (`post_engagement`). Meta-only.
        - `pageEngagement` integer — Meta's own page-engagement total (`page_engagement`). Meta-only.
        - `reactions` integer — Reactions on the ad's post (`post_reaction`). For TikTok these are its paid likes.
        - `comments` integer — Comments on the ad's post.
        - `shares` integer — Shares of the ad's post. Meta reports these under the action type literally named `post`; TikTok under `share`.
        - `saves` integer — Saves of the ad's post (`onsite_conversion.post_save`).
        - `pageLikes` integer — New Page likes attributed to the ad (`like`).
        - `videoViews` integer — 3-second video views (`video_view`). For completion-based counts use `videoThruplayWatchedActions`.
        - `linkClicks` integer — Attributed link clicks (`link_click`). This is the attribution-window count, which differs from the in-session count in the sibling `inlineLinkClicks` field.
      - `lastSyncedAt` string, date-time — Present on individual ads only, not on campaign aggregations
    - `platformAdId` string
    - `platformAdAccountId` string
    - `platformCampaignId` string
    - `platformAdSetId` string
    - `campaignName` string
    - `adSetName` string
    - `platformObjective` string, nullable — Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads.
    - `optimizationGoal` string, nullable — 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, nullable — 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` string[] — 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, nullable — 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, date-time, nullable — 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` 'LOWEST_COST_WITHOUT_CAP' | 'LOWEST_COST_WITH_BID_CAP' | 'COST_CAP' | 'LOWEST_COST_WITH_MIN_ROAS' — Meta bid strategy. Same enum applies at campaign and ad-set level; ad-set value (when set) overrides campaign-level. Cross-field rules: - `LOWEST_COST_WITHOUT_CAP` (default): auto-bid, forbids `bidAmount` and `roasAverageFloor`. - `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: require `bidAmount` (whole currency units). - `LOWEST_COST_WITH_MIN_ROAS`: requires `roasAverageFloor` (decimal multiplier, 2.0 = 2.0x). Source: facebook-business-sdk-codegen api_specs/specs/enum_types.json (`AdSet_bid_strategy`, `Campaign_bid_strategy`).
    - `bidAmount` number, nullable — 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, nullable — 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, nullable — Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads.
      - `custom_event_type` string — Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART)
      - `pixel_id` string — Meta pixel ID
      - `page_id` string — Facebook page ID
      - `application_id` string — Facebook app ID
      - `product_set_id` string — Product catalog set ID
    - `creative` object, nullable — Platform-specific creative data. Fields vary by platform.
      - `assetGroup` GooglePmaxAssetGroupInput — Google Performance Max creative assets. At least one description must be 60 characters or fewer. Texts within each list must be distinct.
        - `name` string — Defaults to the request name.
        - `finalUrl` string, uri, required — Required destination URL.
        - `headlines` string[], required
        - `longHeadline` string, required
        - `descriptions` string[], required — At least one description must be 60 characters or fewer.
        - `businessName` string, required
        - `images` object, required — Public HTTP(S) image URLs. GIF, JPEG or PNG, at most 5120 KB per image. Google validates dimensions and aspect ratios.
          - `landscape` string[], required — Landscape marketing images. Aspect ratio 1.91:1, minimum 600 x 314 pixels.
          - `square` string[], required — Square marketing images. Aspect ratio 1:1, minimum 300 x 300 pixels.
          - `logo` string[], required — Required square logos. Aspect ratio 1:1, minimum 128 x 128 pixels.
        - `youtubeVideoId` string — Optional existing YouTube video id. Google can generate video when omitted. Video uploads and arbitrary video URLs are not supported.
      - `assetGroupResourceName` string — Google resource name of the created Performance Max asset group.
      - `headlines` GoogleRsaHeadline[] — Google RSA only. Replaces the complete headline list. No padding or truncation on update.
        - `text` string, required
        - `pinnedField` 'HEADLINE_1' | 'HEADLINE_2' | 'HEADLINE_3' — Optional fixed headline position. Omit to leave the asset unpinned.
      - `descriptions` GoogleRsaDescription[] — Google RSA only. Replaces the complete description list. No padding or truncation on update.
        - `text` string, required
        - `pinnedField` 'DESCRIPTION_1' | 'DESCRIPTION_2' — Optional fixed description position. Omit to leave the asset unpinned.
      - `finalUrls` string[] — Google RSA only. Replaces final URLs. Omitted lists stay unchanged.
      - `thumbnailUrl` string, nullable — Primary thumbnail/image URL
      - `imageUrl` string — Alternative image URL
      - `videoId` string, nullable — Meta video ID for VIDEO-type ads. Null for non-video ads. Callers that need an embeddable MP4 can call GET /{videoId}?fields=source with the page access token.
      - `videoUrl` string, nullable — Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads.
      - `creativeId` string, nullable — Meta ad creative id backing this ad. Reusable via existingCreativeId on POST /v1/ads/create.
      - `objectType` string — Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews: when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available.
      - `objectStoryId` string, nullable — Meta creative `object_story_id` (the SHARE reference). Frequently absent, because Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead.
      - `effectiveObjectStoryId` string, nullable — Meta `effective_object_story_id`, the `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it.
      - `pageId` string, nullable — Facebook Page backing the creative (Meta only). What the `pageId` filter on /v1/ads, /v1/ads/campaigns and /v1/ads/tree matches against. Absent for non-Meta ads and rare Meta creatives with no page signal.
      - `effectiveInstagramMediaId` string, nullable — Meta `effective_instagram_media_id`, the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad.
      - `instagramUserId` string, nullable — Meta `instagram_user_id`, the Instagram-scoped business ID that owns the boosted media.
      - `instagramPermalinkUrl` string, nullable — Meta `instagram_permalink_url`, the public Instagram post URL of the boosted media.
      - `mediaUrls` string[] — All media URLs for this ad (carousel images, multiple assets). Populated for Meta (carousel child_attachments), Google Ads (responsive display marketing_images), and LinkedIn (multi-image posts).
      - `isServing` boolean, nullable — LinkedIn only. Whether LinkedIn is currently serving this specific creative. Complements the ad-level `servingStatuses`, which describes the parent campaign.
      - `servingHoldReasons` string[] — LinkedIn only. Why this specific creative is not being served. Empty when it is serving. A superset of the ad-level `servingStatuses`: it repeats the inherited campaign, campaign group and account holds AND adds creative-only causes such as UNDER_REVIEW, REJECTED, PROCESSING, PROCESSING_FAILED, FORM_HOLD (lead-gen-form creatives), REFERRED_CONTENT_QUALITY_HOLD, JOB_POSTING_ON_HOLD and JOB_POSTING_INVALID (job ads). Some values are format-specific and will never appear on other ad formats. The list is open, so treat unrecognized values as holds rather than errors.
      - `body` string — Ad copy/text
      - `googleHeadline` string — Google Ads headline
      - `googleDescription` string — Google Ads description
      - `linkUrl` string — Destination URL
      - `whatsappPhoneNumber` string — Explicit E.164 WhatsApp number supplied when creating a Meta boost or messaging ad. Absent when omitted by the caller or on older records.
      - `pinterestImageUrl` string
      - `pinterestTitle` string
      - `pinterestDescription` string
    - `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, nullable
      - `startDate` string, date-time
      - `endDate` string, date-time
    - `rejectionReason` string
    - `createdAt` string, date-time
    - `updatedAt` string, date-time
  - `message` string

## Other responses

- `400` — Invalid status transition, budget below minimum, a LinkedIn creative update without imageUrl or videoUrl, a LinkedIn targeting update without countries or regions, or a Google targeting update that is unsupported, empty, mixes locations with the top-level geo fields, or names an unknown country or language code
- `401` — Unauthorized
- `403` — 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.
- `404` — Resource not found
- `422` — The ad has no campaign or ad group on the platform yet, the Google targeting edit asks for something that is create-only (`locations.customLocations`), or a creative field the ad's channel cannot carry: assetGroup on a non-Performance-Max ad, a Google Display field on a Search ad, a pinnedField on a Display headline, or any Google-only field on another platform. A Google creative edit that cannot reach Google at all (the ad has no `platformAdId`, or its ad account cannot be loaded) also returns 422 rather than a 200 that changed nothing.
- `429` — Meta admits one write per 30 seconds to a metered object, ad creatives above all. Zernio waits out two of those windows and replays the call before surfacing this, so it only appears when the object is being edited faster than that. Retry in 30 seconds.
- `501` — targeting or creative not supported on the platform (supported on Meta, TikTok, and LinkedIn)
- `502` — Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`.

## Changes

- **2026-09-12** `06acc34a7017` — 7 warning, 9 info
  - removed the request property `creative/promotion/code`
  - removed the request property `creative/promotion/endDate`
  - removed the request property `creative/promotion/startDate`
  - removed the request property `creative/promotion/type`
  - …12 more
- **2026-09-10** `e70ed06e7150` — 2 info
  - added the optional property `ad/creative/assetGroup` to the response with the `200` status
  - added the optional property `ad/creative/assetGroupResourceName` to the response with the `200` status
- **2026-09-09** `222e3a38a382` — 11 info
  - added the new optional request property `creative/creativeFeatures`
  - added the new optional request property `creative/promotion`
  - added the new optional request property `descriptions`
  - added the new optional request property `finalUrls`
  - …7 more
- **2026-09-08** `d7a8fa118ef0` — 2 info
  - added the new optional request property `targeting/devices`
  - added the optional property `ad/metrics/anyOf[#/components/schemas/AdMetrics]/allConversions` to the response with the `200` status
- **2026-09-02** `017dff7e4324` — 4 info
  - added the new optional request property `creative/description`
  - added the new optional request property `creative/existingCreativeId`
  - added the new optional request property `creative/videoId`
  - added the non-success response with the status `403`

[Full history](https://skmtc.dev/zernio/apis/zernio-api/changes/v1/ads/:adId/put.md)

---

[API](https://skmtc.dev/zernio/apis/zernio-api.md) · [All operations](https://skmtc.dev/zernio/apis/zernio-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zernio/apis/zernio-api/revisions/fdaf9a9fb1a7?raw)
