Zernio
APIFor you
Zernio
APIFor you
Platforms

Meta Ads

Overview

Set up

Connect

Build

CampaignsAd setsCreativesCreative libraryPreviewsBoost a postLead Gen Forms

Target

TargetingCustom Audiences

Ad types

Creative testingConversion campaignsCatalog adsMessaging & call adsClick-to-WhatsApp adsReach & frequency

Measure

InsightsMeta PixelsConversions APIAd URL tracking tags

Operate

Duplicate & lifecycleAccount & ops readsAd LibraryAd comments

Reference

Enums, limits & error codes
Dashboard
OverviewPlatformsAPI ReferenceResources
Meta Ads

Campaigns

Create a Meta campaign, ad set and ad in one call, set the bid strategy and its value rule sets, and read the campaign tree back with rolled-up metrics.


POST /v1/ads/create builds a campaign, an ad set and an ad in one call. GET /v1/ads/tree reads the hierarchy back, with metrics rolled up at every level.

Every other page in this section changes a few fields of the create request below, or edits one level of the tree afterwards.

Before you start

You need a connected Meta posting or metaads accountId and an adAccountId (find them).

New Meta campaigns default to buyingType: "AUCTION" on both POST /v1/ads/create and POST /v1/ads/campaigns, so you can omit the field. Attaching to existingCampaignId leaves the existing campaign's buying type unchanged.

Create a campaign

Call POST /v1/ads/create with the flat body below. Every field sits at the top level.

The platform is inferred from accountId, so there is no platform field. budgetAmount is in whole currency units of the ad account.

import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: created } = await zernio.adcampaigns.createStandaloneAd({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    adAccountId: 'act_1234567890',
    name: 'Spring sale - US feed',
    goal: 'traffic',
    budgetAmount: 75,
    budgetType: 'daily',
    headline: 'Spring sale, 30% off',
    body: 'Limited time. Upgrade today.',
    imageUrl: 'https://cdn.example.com/spring.jpg',
    callToAction: 'SHOP_NOW',
    linkUrl: 'https://example.com/spring',
    countries: ['US'],
    ageMin: 25,
    ageMax: 55,
    interests: [{ id: '6003139266461', name: 'DevOps' }]
  }
});

const adId = created.ad._id;

Response (201):

{
  "ad": {
    "_id": "66d4a1b2c3e4f5a6b7c8d9e2",
    "name": "Spring sale - US feed",
    "platform": "facebook",
    "status": "pending_review",
    "configuredStatus": "ACTIVE",
    "reviewStatus": "in_review",
    "adType": "standalone",
    "goal": "traffic",
    "budget": { "amount": 75, "type": "daily" },
    "platformAdId": "120260000000000000",
    "platformAdAccountId": "act_1234567890",
    "platformCampaignId": "120250000000000000",
    "platformAdSetId": "120250000000000001",
    "platformObjective": "OUTCOME_TRAFFIC",
    "optimizationGoal": "LINK_CLICKS",
    "creative": {
      "creativeId": "120250000000000005",
      "pageId": "811889972008357",
      "imageUrl": "https://cdn.example.com/spring.jpg",
      "linkUrl": "https://example.com/spring"
    }
  },
  "message": "Ad created"
}

The ids you reuse from this response:

  • ad._id is the adId for every per-ad call.
  • platformCampaignId and platformAdSetId are the campaignId and adSetId for the campaign and ad set endpoints.
  • creative.creativeId can be reused with existingCreativeId.

The ad is created ACTIVE and enters Meta review. Pass status: "PAUSED" to review it before it spends.

The request is not idempotent. Send an Idempotency-Key header before you add a retry (idempotency).

A top-level tracking object (pixelId, urlTags) attaches pixel measurement and click-URL params at create time. See set tags at create time.

The samples on the other pages of this section change only a few fields of this request:

You wantChangePage
goal of conversions, lead_conversion, lead_generation, app_promotion or catalog_salesadd promotedObjectConversion campaigns
A video, a carousel or one asset per placementreplace imageUrlCreatives
N ads in one ad setadd creatives[]Creative testing
One more ad in an ad set that already runsadd adSetIdCreatives
Cities, regions or ZIPs instead of countriesreplace countriesTargeting
A fixed-price reservationadd buyingType: "RESERVED"Reach and Frequency
Validate without creatingadd validateOnly: trueLifecycle

POST /v1/ads/boost uses a different body, with nested budget, schedule and targeting objects (boost a post). Do not mix the two shapes.

iOS 14+ SKAdNetwork attribution

For goal: "app_promotion" on iOS, send these together (every promoted object key):

  • isSkadnetworkAttribution: true
  • campaignAttribution: "SKADNETWORK"
  • promotedObject.applicationId / promotedObject.objectStoreUrl

isSkadnetworkAttribution is immutable once the campaign exists. The campaign only receives promotedObject at all when it is true.

Plain Android installs keep promotedObject on the ad set rather than the campaign.

campaignAttribution requires AUCTION buying and only exists on this combined create call. Creating an ad set on its own has no equivalent field.

Create a campaign only

Call POST /v1/ads/campaigns to create a campaign without its first ad set and ad.

Use it to provision the container first. Ad sets join it later through existingCampaignId on POST /v1/ads/create.

const { data: campaign } = await zernio.adcampaigns.createAdCampaign({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    adAccountId: 'act_1234567890',
    name: 'Q4 push',
    goal: 'conversions',
    budgetAmount: 50,
    budgetType: 'daily'
  }
});

const campaignId = campaign.campaignId;

Response (201):

{
  "adAccountId": "act_1234567890",
  "campaignId": "120250000000000000",
  "objective": "OUTCOME_SALES",
  "status": "PAUSED"
}

goal maps to the same Meta objective as on POST /v1/ads/create. objective is the resolved value.

A budget here is campaign-level (CBO) by definition. Omit it for ABO, where each ad set brings its own budget.

The campaign is created PAUSED unless you pass status: "ACTIVE".

isSkadnetworkAttribution: true also works here for a goal: "app_promotion" campaign container (SKAdNetwork attribution). It is immutable once created.

Empty campaigns. A campaign with zero ads is invisible to GET /v1/ads/tree and GET /v1/ads/campaigns until an ad set joins it. Pass includeEmpty=true on the campaign list to see it.

Deleting an empty campaign needs accountId in the body (lifecycle).

For a campaign-only dry run, send validateOnly: true to POST /v1/ads/campaigns. It returns 200 with status: "VALIDATED" and creates no campaign.

Bid strategy

Meta's bid strategies are accepted on:

  • POST /v1/ads/create
  • POST /v1/ads/boost
  • PUT /v1/ads/ad-sets/{adSetId}
  • PUT /v1/ads/campaigns/{campaignId}

They are returned on GET /v1/ads/tree, GET /v1/ads/campaigns and GET /v1/ads/{adId}.

bidStrategyRequired companion fieldMeta behaviour
LOWEST_COST_WITHOUT_CAP (default)noneAuto-bid; Meta spends the budget.
LOWEST_COST_WITH_BID_CAPbidAmountAuto-bid with a hard ceiling per result.
COST_CAPbidAmountTarget average cost per result.
LOWEST_COST_WITH_MIN_ROASroasAverageFloorNeeds a value-optimized campaign (OUTCOME_SALES with a pixel or dataset).

bidAmount is in whole currency units of the ad account (USD 5 is $5.00, JPY 100 is ¥100). Zernio converts it to Meta's smallest-denomination integer.

roasAverageFloor is a decimal multiplier (2.0 is 2.0x ROAS). It is sent to Meta as bid_constraints.roas_average_floor times 10,000.

On create, send the 3 fields inside platformSpecificData. The flat top-level fields still work during Meta's deprecation window. Sending both shapes returns a 400.

A $5 cost cap on the create request above:

{
  "goal": "conversions",
  "promotedObject": { "pixelId": "1729525464415281", "customEventType": "PURCHASE" },
  "platformSpecificData": { "bidStrategy": "COST_CAP", "bidAmount": 5 }
}

To change a running ad set's bid, call PUT /v1/ads/ad-sets/{adSetId} with bidStrategy plus bidAmount or roasAverageFloor (ad sets).

PUT /v1/ads/campaigns/{campaignId} accepts bidStrategy only, because Meta has no bid_amount or bid_constraints at the campaign level. It works only on a CBO campaign. An ABO campaign returns 409 pointing at the ad set endpoint.

Value rule sets

A value rule set moves the auction bid up or down for audience segments worth more or less to you. You do not split them into their own ad sets.

POST /v1/ads/value-rule-sets creates one on the ad account. Each rule names the criteria it fires on and a direction:

  • INCREASE, 1 to 1000 percent
  • DECREASE, 1 to 90
curl -X POST "https://zernio.com/api/v1/ads/value-rule-sets" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "adAccountId": "act_1234567890",
    "name": "Repeat markets",
    "rules": [{
      "name": "Bid up in Canada",
      "adjustSign": "INCREASE",
      "adjustValue": 30,
      "criteria": [{
        "criteriaType": "LOCATION",
        "operator": "CONTAINS",
        "criteriaValues": ["CA"],
        "criteriaValueTypes": ["LOCATION_COUNTRY"]
      }]
    }]
  }'

Response (201):

{ "adAccountId": "act_1234567890", "valueRuleSetId": "120250000000000042" }

Attach the id with valueRuleSetId on PUT /v1/ads/ad-sets/{adSetId}, or on POST /v1/ads/create when the ad set is being created.

Sending a different id replaces the association. There is no separate replace call.

Detach. Send valueRulesApplied: false and omit valueRuleSetId. The 2 fields together return a 400 with code mutually_exclusive_fields, because Meta keeps the adjustments live whenever the id is present.

Read the association back with GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id. Meta exposes no readable field for the boolean.

Rules only apply to ad sets on LOWEST_COST_WITHOUT_CAP or COST_CAP. Meta rejects the rest itself.

The rest of the set:

CallWhat it does
GET /v1/ads/value-rule-setsLists the account's sets in the same shape the update takes, ids included
GET /v1/ads/value-rule-sets/{valueRuleSetId}One set with every nested rule and criterion id
PUT /v1/ads/value-rule-sets/{valueRuleSetId}Replaces the set: echo an id to keep a rule, omit one to delete it
DELETE /v1/ads/value-rule-sets/{valueRuleSetId}Deletes the set; detach the ad sets first, since this call leaves them pointing at it

The update is a full replace, not a patch: anything left out is deleted on Meta with no warning. GET the set first and send every rule and criterion you want to keep, with its id.

Rule order is semantic too, since only the first rule that matches an overlapping audience adjusts the bid.

Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule.

Reading the campaign tree

Call GET /v1/ads/tree. It returns campaigns, their ad sets and their ads nested, with metrics rolled up over the date range.

The range defaults to the last 90 days, at most 730 days. Pagination is per campaign.

const { data: tree } = await zernio.adcampaigns.getAdTree({
  query: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', fromDate: '2026-08-01', toDate: '2026-08-31' }
});

console.log(tree.campaigns[0].budgetLevel);

Response (200), trimmed to one campaign:

{
  "campaigns": [
    {
      "platformCampaignId": "120250000000000000",
      "platform": "facebook",
      "campaignName": "Spring sale - US feed - Campaign",
      "status": "active",
      "reviewStatus": "approved",
      "platformCampaignStatus": "ACTIVE",
      "campaignIssuesInfo": null,
      "adCount": 1,
      "adSetCount": 1,
      "budgetLevel": "adset",
      "campaignBudget": null,
      "isBudgetScheduleEnabled": false,
      "currency": "USD",
      "metrics": { "spend": 493.39, "impressions": 88210, "clicks": 1205, "roas": 4.98 },
      "adSets": [
        {
          "platformAdSetId": "120250000000000001",
          "adSetName": "Spring sale - US feed - Ad Set",
          "status": "active",
          "adSetBudget": { "amount": 75, "type": "daily" },
          "optimizationGoal": "LINK_CLICKS",
          "ads": [
            { "_id": "66d4a1b2c3e4f5a6b7c8d9e2", "name": "Spring sale - US feed", "status": "active", "configuredStatus": "ACTIVE", "reviewStatus": "approved" }
          ]
        }
      ]
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "pages": 1 }
}

Each campaign node carries:

FieldValuesMeaning
statusactive, paused, pending_review, rejected, completed, cancelled, errorDelivery status derived from the child ads
reviewStatusin_review, approved, rejected, with_issues, nullMeta's review, separate from status
platformCampaignStatusstringMeta's raw Campaign.effective_status
campaignIssuesInfoobject[] or nullMeta's raw issues_info[] when delivery issues exist
budgetLevelcampaign, adset, nullcampaign is CBO, adset is ABO
campaignBudget{ amount, type } or nullSet on CBO campaigns only
adSetBudget (on ad set nodes){ amount, type } or nullSet on ABO campaigns only
isBudgetScheduleEnabledbooleanMeta's Campaign.is_budget_schedule_enabled
currencyISO 4217Budgets are in the ad account's currency and are never normalized

Backfill. A 202 with backfillPending: true and a Retry-After header, instead of the 200, means part of the range is still being backfilled. A freshly connected ad account hits this.

The 202 body has the same shape. Retry after the delay until the call returns 200.

Sources. source=all (the default, the same view as the dashboard) returns ads created through Zernio and ads discovered from Ads Manager. source=zernio restricts to the former.

Deleted objects stay in the tree as cancelled so their historical spend keeps counting. Filter on status after reading the totals.

timeIncrement=1 adds a daily[] series to each node.

Search the tree

search finds one branch without paging the whole tree. It matches either:

  • a case-insensitive substring of campaign, ad set and ad names
  • an exact platform campaign, ad set or ad id

A campaign whose name matches comes back with all its ad sets and ads. A match on an ad set or ad name returns only that branch.

It filters the campaign set itself, so pagination.total counts only the matching campaigns:

curl "https://zernio.com/api/v1/ads/tree?accountId=66b2e19d8c3f5a7e9d0b1c2d&search=spring%20sale" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"

Reading those numbers is Insights:

  • The monetary fields on metrics are in ROAS and revenue per event.
  • The 3 status fields an ad node carries are in status axes on ads.

Filter by Facebook Page

A Meta ad account serves ads for every Page in the Business Manager.

pageId on GET /v1/ads/tree, GET /v1/ads and GET /v1/ads/campaigns prunes to one Page:

  • On the tree: only ads whose creative is backed by that Page. Campaigns and ad sets with no ad on the Page drop out, and rolled-up metrics cover only the Page's ads.
  • On the campaign list: campaigns with at least one ad on the Page, with adCount and metrics computed over those ads only.
  • On the ad list: each ad's creative.pageId, also returned on reads, so you can group client-side.

Meta only. The few creatives with no Page signal (some Instagram-only ones) never match.

Update a CBO campaign budget

Call PUT /v1/ads/campaigns/{campaignId} with platform and budget when the campaign node reads budgetLevel: "campaign".

platform is required because Meta campaign ids are not globally unique across the platforms Zernio supports.

const { data: updated } = await zernio.adcampaigns.updateAdCampaign({
  path: { campaignId: '120250000000000000' },
  body: {
    platform: 'facebook',
    budget: { amount: 250, type: 'daily' }
  }
});

Response (200):

{
  "updated": 1,
  "budget": { "amount": 250, "type": "daily" },
  "budgetLevel": "campaign"
}

The same endpoint renames the campaign with name. For an ABO campaign, update the budget on the ad set instead.

Campaign spend cap

The same endpoint accepts platformSpecificData.spendCap. It is a lifetime ceiling for the whole campaign, in whole currency units, independent of the daily or lifetime budget.

Set a $1,000 cap:

{ "platform": "facebook", "platformSpecificData": { "spendCap": 1000 } }

Remove it with null, never with 0:

{ "platform": "facebook", "platformSpecificData": { "spendCap": null } }

Meta rejects a spend cap of zero (subcode 1885099, "Spend Cap Can't Be Zero"). Its removal sentinel is a magic number Zernio sends when it receives null.

An unknown key inside platformSpecificData, or a non-Meta platform, returns a 400.

This is the campaign cap. The account-level cap is read from account finances.

If it fails

A 409 with code: "BUDGET_LEVEL_MISMATCH" means you sent a budget to the wrong level of the tree:

{
  "error": "Campaign is ABO, route to /v1/ads/ad-sets/{adSetId} instead",
  "type": "invalid_request_error",
  "code": "BUDGET_LEVEL_MISMATCH"
}

Read budgetLevel on the campaign node, then send the budget to:

  • PUT /v1/ads/campaigns/{campaignId} when it is campaign
  • PUT /v1/ads/ad-sets/{adSetId} when it is adset

A 400 with type: "platform_error" carries Meta's own message and subcode in platformError. The reference lists the ones worth recognizing.

Related

  • Ad sets: ABO budgets, schedule, optimization goal and the learning phase.
  • Lifecycle: pause, resume, duplicate, delete and dry run.
  • Insights: breakdowns and live Meta queries beyond the rolled-up metrics.
  • Create standalone ad and Get campaign tree: every field.
  • Idempotency: the Idempotency-Key header on creates.
Was this page helpful?

Connect

Connect a Meta Ads account with the Zernio API, every step with code.

Ad sets

Edit a Meta ad set after launch: ABO budget, status, optimization goal, schedule, promoted object, read its learning phase live, and delete it.

On this page

Before you startCreate a campaigniOS 14+ SKAdNetwork attributionCreate a campaign onlyBid strategyValue rule setsReading the campaign treeSearch the treeFilter by Facebook PageUpdate a CBO campaign budgetCampaign spend capIf it failsRelated