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._idis theadIdfor every per-ad call.platformCampaignIdandplatformAdSetIdare thecampaignIdandadSetIdfor the campaign and ad set endpoints.creative.creativeIdcan be reused withexistingCreativeId.
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 want | Change | Page |
|---|---|---|
goal of conversions, lead_conversion, lead_generation, app_promotion or catalog_sales | add promotedObject | Conversion campaigns |
| A video, a carousel or one asset per placement | replace imageUrl | Creatives |
| N ads in one ad set | add creatives[] | Creative testing |
| One more ad in an ad set that already runs | add adSetId | Creatives |
| Cities, regions or ZIPs instead of countries | replace countries | Targeting |
| A fixed-price reservation | add buyingType: "RESERVED" | Reach and Frequency |
| Validate without creating | add validateOnly: true | Lifecycle |
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: truecampaignAttribution: "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/createPOST /v1/ads/boostPUT /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}.
bidStrategy | Required companion field | Meta behaviour |
|---|---|---|
LOWEST_COST_WITHOUT_CAP (default) | none | Auto-bid; Meta spends the budget. |
LOWEST_COST_WITH_BID_CAP | bidAmount | Auto-bid with a hard ceiling per result. |
COST_CAP | bidAmount | Target average cost per result. |
LOWEST_COST_WITH_MIN_ROAS | roasAverageFloor | Needs 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 percentDECREASE, 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:
| Call | What it does |
|---|---|
GET /v1/ads/value-rule-sets | Lists 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:
| Field | Values | Meaning |
|---|---|---|
status | active, paused, pending_review, rejected, completed, cancelled, error | Delivery status derived from the child ads |
reviewStatus | in_review, approved, rejected, with_issues, null | Meta's review, separate from status |
platformCampaignStatus | string | Meta's raw Campaign.effective_status |
campaignIssuesInfo | object[] or null | Meta's raw issues_info[] when delivery issues exist |
budgetLevel | campaign, adset, null | campaign is CBO, adset is ABO |
campaignBudget | { amount, type } or null | Set on CBO campaigns only |
adSetBudget (on ad set nodes) | { amount, type } or null | Set on ABO campaigns only |
isBudgetScheduleEnabled | boolean | Meta's Campaign.is_budget_schedule_enabled |
currency | ISO 4217 | Budgets 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
metricsare 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
adCountand 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 iscampaignPUT /v1/ads/ad-sets/{adSetId}when it isadset
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-Keyheader on creates.