Campaigns
Create standalone campaigns, bid strategy, ROAS, and reading the campaign tree
Create the full tree in one call
POST /v1/ads/create builds campaign, ad set and ad together, which is the shape you want almost every time:
const ad = await zernio.adcampaigns.createStandaloneAd({ body: {
accountId: 'acc_metaads_123',
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,
}});For goal: "conversions", "lead_conversion", "lead_generation", "app_promotion", or "catalog_sales", see Conversion campaigns and promoted objects — Meta requires a promotedObject (Pixel + event, Page, or App) on the ad set for those optimization goals.
/v1/ads/create uses a flat body (every field at the top level). The /v1/ads/boost endpoint is different, it uses nested budget, schedule, and targeting objects. Don't mix the two shapes. Platform is inferred from accountId, so no platform field in the body.
Before spending, you can validate the whole tree against Meta with validateOnly: true, see Dry-run a create.
Create a campaign only
POST /v1/ads/campaigns creates a campaign without its first ad set and ad, for when you provision the container first and let ad sets join it later:
const { data } = await zernio.adcampaigns.createAdCampaign({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
name: 'Q4 Push',
goal: 'conversions',
budgetAmount: 50,
budgetType: 'daily',
},
});
// data.campaign.objective -> the resolved Meta objectivegoalmaps to the ODAX objective exactly likePOST /v1/ads/create; the response returns the resolvedobjective.- A budget here is campaign-level (CBO) by definition. Omit it for ABO, where each ad set brings its own budget later.
- Created
PAUSEDunless you passstatus: "ACTIVE". - Ad sets join it via
existingCampaignIdon the create endpoints, and it shows up in/v1/ads/treeafter the next sync pass.
Bid strategy
Meta's full bid-strategy enum is supported on writes (POST /v1/ads/create, POST /v1/ads/boost, PUT /v1/ads/ad-sets/{adSetId}, PUT /v1/ads/campaigns/{campaignId}) and surfaced on reads (GET /v1/ads/tree, /v1/ads/campaigns, /v1/ads/{adId}).
bidStrategy | Required companion field | Notes |
|---|---|---|
LOWEST_COST_WITHOUT_CAP (default) | — | Auto-bid; Meta optimizes to spend the budget. |
LOWEST_COST_WITH_BID_CAP | bidAmount (whole currency units) | Auto-bid with a hard ceiling. |
COST_CAP | bidAmount (whole currency units) | Target average cost per result. |
LOWEST_COST_WITH_MIN_ROAS | roasAverageFloor (decimal multiplier, e.g. 2.0 = 2.0× ROAS) | Requires a value-optimized campaign (e.g. OUTCOME_SALES with a connected pixel/dataset). |
bidAmount is whole currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100). Internally converted to Meta's smallest-denomination integer (cents for USD).
roasAverageFloor is a decimal multiplier; we encode it as Meta's bid_constraints.roas_average_floor × 10000 (so 2.0 → 20000).
Create an ad with a $5 cost cap:
const result = await zernio.adcampaigns.createStandaloneAd({
body: {
accountId: 'ACCOUNT_ID',
adAccountId: 'act_123',
name: 'Spring sale',
goal: 'conversions',
budgetAmount: 50,
budgetType: 'daily',
headline: 'Spring sale',
body: '20% off everything',
callToAction: 'SHOP_NOW',
linkUrl: 'https://example.com',
imageUrl: 'https://cdn.example.com/banner.jpg',
bidStrategy: 'COST_CAP',
bidAmount: 5,
promotedObject: { pixelId: '1729525464415281', customEventType: 'PURCHASE' },
},
});To switch a running ad set's bid strategy, use PUT /v1/ads/ad-sets/{adSetId} with bidStrategy (+ roasAverageFloor / bidAmount as required), see Ad Sets.
Campaign-level PUT /v1/ads/campaigns/{campaignId} accepts bidStrategy only — Meta's spec has no bid_amount or bid_constraints at the campaign level (those live on the ad set). Campaign-level bid edits also require the campaign to be CBO (campaign-level budget); ABO campaigns return 409 with a pointer to the ad-set endpoint.
ROAS + revenue-per-event
Every metrics object on /v1/ads/tree, /v1/ads/campaigns, and /v1/ads/{adId} carries three Meta-specific monetary fields alongside the existing actions + conversions counts:
| Field | Type | Notes |
|---|---|---|
actionValues | {[action_type]: number} | Monetary mirror of actions, from Meta's action_values[]. Values in ad-account native currency (see the campaign's currency field). Populated for the same action types Meta reports values on (purchases, AddToCart with value, etc.). |
purchaseValue | number | Convenience sum of purchase-type action values, picked from actionValues via the same priority list as conversions (offsite_conversion.fb_pixel_purchase → omni_purchase → purchase). Same unit as spend. |
roas | number | Derived purchaseValue / spend. Recomputed from summed numerator + denominator at ad-set and campaign levels (not averaged across children) so the rollup is mathematically correct. Equivalent to Meta's purchase_roas under default attribution. |
Example campaign rollup for a purchase campaign:
"metrics": {
"spend": 493.39,
"purchaseValue": 2456.78,
"roas": 4.98,
"conversions": 42,
"costPerConversion": 11.75,
"actions": { "offsite_conversion.fb_pixel_purchase": 42, "add_to_cart": 138, "link_click": 1205 },
"actionValues": { "offsite_conversion.fb_pixel_purchase": 2456.78, "add_to_cart": 4230.50 }
}For cost-per-AddToCart, cost-per-Lead, etc., read the relevant actions[key] divided by spend. Pick offsite_conversion.fb_pixel_* keys in actions / actionValues when available to avoid Meta's parallel pixel+omni+canonical reporting (same conversion counted under multiple keys).
Reading the campaign tree
GET /v1/ads/tree returns nested Campaign → Ad Set → Ad with rolled-up metrics
(including conversions and raw actions counts). Each campaign node exposes:
| Field | Type | Notes |
|---|---|---|
status | active | paused | pending_review | … | Delivery status derived from child ads |
reviewStatus | in_review | approved | rejected | with_issues | null | Platform-side review, distinct from status |
platformCampaignStatus | string | Raw Meta Campaign.effective_status |
campaignIssuesInfo | object[] | null | Meta's raw issues_info[] when delivery issues exist |
budgetLevel | campaign | adset | null | Canonical CBO/ABO switch |
campaignBudget | { amount, type } | null | Populated only for CBO |
adSetBudget (on ad-set nodes) | { amount, type } | null | Populated only for ABO |
isBudgetScheduleEnabled | boolean | Mirrors Meta Campaign.is_budget_schedule_enabled |
currency | string | ISO 4217 code, budgets are in ad-account native currency, NOT normalized |
Default ?source=all (matches the Zernio UI) returns both Zernio-created and
platform-discovered ads. Pass ?source=zernio to restrict.
Status axes on ads
Each ad node carries three distinct status fields, and conflating them is the classic dashboard bug:
| Field | Answers | Notes |
|---|---|---|
status | Is it delivering? | Derived from Meta's effective_status, so it inherits ancestor pauses: an ACTIVE ad under a PAUSED campaign reads paused. Ads whose campaign/ad-set schedule has ended read completed. |
configuredStatus | What did you set? | The ad's own on/off toggle, unaffected by ancestors. |
reviewStatus | What does Meta's review say? | in_review / approved / rejected / with_issues. A rejected ad and a paused-by-you ad are different problems; this is how you tell them apart. |
Filter by Facebook Page
A Meta ad account serves ads for every Page in the Business Manager. ?pageId= (on /v1/ads/tree, /v1/ads, and /v1/ads/campaigns) prunes to one Page:
- On
/v1/ads/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
/v1/ads/campaigns: keeps campaigns having at least one ad on the Page, withadCountand metrics computed over those ads only. - On
/v1/ads: matches each ad'screative.pageId(also returned on reads, so you can group client-side).
Meta only. Rare creatives with no page signal (some IG-only ones) never match.
Update a CBO campaign budget
Use PUT /v1/ads/campaigns/{campaignId} when budgetLevel === 'campaign'.
If you call this on an ABO campaign, Zernio returns 409 with
code: "BUDGET_LEVEL_MISMATCH", route to the ad-set endpoint instead.
await zernio.adcampaigns.updateAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: {
platform: 'facebook',
budget: { amount: 250, type: 'daily' },
},
});Campaign spend cap
The same endpoint accepts platformSpecificData.spendCap: a lifetime ceiling across the whole campaign, in whole currency units, independent of the daily/lifetime budget.
// Set a $1,000 lifetime cap
await zernio.adcampaigns.updateAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: { platform: 'facebook', platformSpecificData: { spendCap: 1000 } },
});
// Remove the cap
await zernio.adcampaigns.updateAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: { platform: 'facebook', platformSpecificData: { spendCap: null } },
});Removal is explicit null, not 0. Meta rejects a spend cap of zero (subcode 1885099, "Spend Cap Can't Be Zero"); its removal sentinel is a magic number Zernio sends for you when it receives spendCap: null. Unknown keys inside platformSpecificData return a 400, and so does a non-Meta platform.
This is the campaign cap. The account-level spend cap is read via Account finances.
Everything else
- Ad-set budgets, schedule, optimization goal,
promotedObjectand the learning phase: Ad Sets - Pause / resume (single and bulk), duplicate at all three levels, delete, and dry-run: Duplicate & Lifecycle
- Raw insights beyond the rolled-up metrics: Insights