Meta Ads
Create and boost Facebook + Instagram ads via Zernio API - Campaigns, Custom Audiences, Lookalikes, Pixels, and analytics
Included with the Usage plan. No Meta App review needed. Zernio is an approved Meta Marketing Partner, so you skip the App Review + ads_management permissions entirely.
accountId accepts either shape. You can pass the posting account ID (facebook / instagram) or the ads credential account ID (metaads), Zernio resolves the sibling internally. For Instagram ads, Zernio auto-resolves the linked Facebook Page and the Instagram Business Account ID (no extra field needed). If the Instagram account has no linked Facebook account, the API returns code: "linked_account_required" so you can surface a reconnect flow.
What's Supported
| Feature | Status |
|---|---|
| Standalone campaigns (FB + IG) | Yes |
| Multi-creative campaigns (1 campaign → N ads) | Yes |
| Attach creative to existing ad set | Yes |
| Video creatives (all 3 shapes) | Yes |
| Boost organic posts | Yes |
| CBO vs ABO budget routing + updates | Yes |
| Campaign duplication (deep copy) | Yes |
| Campaign deletion (cascades to ad sets + ads) | Yes |
| Bulk pause / resume campaigns | Yes |
| Ad-set-scoped pause / resume | Yes |
| Custom Audiences (customer list, website, lookalike) | Yes |
| Detailed targeting (interests, age, gender, location) | Yes |
| Campaign > Ad Set > Ad hierarchy | Yes |
| Real-time analytics (spend, CPC, CPM) | Yes |
| ROAS + action_values (revenue per action_type) | Yes |
| Pixel management — create, install code, rename, share, stats (details) | Yes |
| Conversions API (offline events, hashed PII, dedup) | Yes |
| Click-to-WhatsApp ads (CTWA) | Yes |
| Ad comments (read + moderate dark posts) | Yes |
| Ad Library API | Roadmap |
| Advantage+ campaigns | Roadmap |
Ad Comments
Comments on ad creatives (including dark posts, ads created directly in Meta Ads Manager that never went live organically on the Page feed) aren't reachable through the standard GET /v1/inbox/comments/{postId} endpoint, because dark posts don't live in Zernio's post database and Meta's public media-level Graph API endpoints don't resolve them.
Use GET /v1/ads/{adId}/comments instead. It resolves the creative's underlying story via the Marketing API (effective_object_story_id on Facebook, effective_instagram_media_id on Instagram), then fetches the comments from the Graph API. The response matches the inbox endpoint shape so you can reuse the same rendering logic.
const { comments, meta } = await zernio.ads.getAdComments({
path: { adId: 'AD_ID' },
query: { limit: 50 },
});
console.log(meta.platform); // 'facebook' | 'instagram'
console.log(meta.effectiveStoryId); // underlying post ID
for (const c of comments) {
console.log(c.from.name, c.message);
}res = client.ads.get_ad_comments(
ad_id="AD_ID",
limit=50,
)
for c in res["comments"]:
print(c["from"]["name"], c["message"])curl "https://api.zernio.com/v1/ads/AD_ID/comments?limit=50" \
-H "Authorization: Bearer $ZERNIO_API_KEY"Returns ad_not_commentable when the creative format does not expose an underlying commentable post (Story ads, Dynamic Product Ads). Returns feature_not_available for non-Meta ad platforms.
Boost a Post
const ad = await zernio.ads.boostPost({ body: {
postId: 'POST_ID',
accountId: 'ACCOUNT_ID',
adAccountId: 'act_1234567890',
name: 'Spring launch boost',
goal: 'traffic',
budget: { amount: 40, type: 'daily' },
schedule: { startDate: '2026-04-20', endDate: '2026-04-27' },
targeting: {
ageMin: 25,
ageMax: 45,
countries: ['US', 'CA'],
interests: [{ id: '6003139266461', name: 'DevOps' }]
}
}});ad = client.ads.boost_post(
post_id="POST_ID",
account_id="ACCOUNT_ID",
ad_account_id="act_1234567890",
name="Spring launch boost",
goal="traffic",
budget={"amount": 40, "type": "daily"},
schedule={"startDate": "2026-04-20", "endDate": "2026-04-27"},
targeting={"ageMin": 25, "ageMax": 45, "countries": ["US", "CA"]},
)curl -X POST "https://zernio.com/api/v1/ads/boost" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"postId": "POST_ID",
"accountId": "ACCOUNT_ID",
"adAccountId": "act_1234567890",
"name": "Spring launch boost",
"goal": "traffic",
"budget": { "amount": 40, "type": "daily" },
"schedule": { "startDate": "2026-04-20", "endDate": "2026-04-27" },
"targeting": {
"ageMin": 25,
"ageMax": 45,
"countries": ["US", "CA"]
}
}'Create a Standalone Campaign
const ad = await zernio.ads.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,
}});ad = client.ads.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
name="Spring sale - US Feed",
goal="traffic",
budget_amount=75,
budget_type="daily",
headline="Spring Sale - 30% off",
body="Limited time. Upgrade today.",
image_url="https://cdn.example.com/spring.jpg",
call_to_action="SHOP_NOW",
link_url="https://example.com/spring",
countries=["US"],
age_min=25,
age_max=55,
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"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,
"interests": [{ "id": "6003139266461", "name": "DevOps" }]
}'For goal: "conversions", "lead_generation", or "app_promotion", see Conversion campaigns and promoted objects below — 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.
Manage Campaigns & Ad Sets
Zernio exposes the full Meta campaign tree with CBO/ABO awareness, review-state,
and budget-level routing, enough to build a Meta Ads Manager replacement
on top of GET /v1/ads/tree and the write endpoints below.
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.ads.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' },
},
});result = client.ads.create_standalone_ad(
account_id="ACCOUNT_ID",
ad_account_id="act_123",
name="Spring sale",
goal="conversions",
budget_amount=50,
budget_type="daily",
headline="Spring sale",
body="20% off everything",
call_to_action="SHOP_NOW",
link_url="https://example.com",
image_url="https://cdn.example.com/banner.jpg",
bid_strategy="COST_CAP",
bid_amount=5,
promoted_object={"pixelId": "1729525464415281", "customEventType": "PURCHASE"},
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"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" }
}'Switch an ad set to ROAS-floor bidding:
await zernio.adcampaigns.updateAdSet({
path: { adSetId: 'AD_SET_ID' },
body: {
platform: 'facebook',
bidStrategy: 'LOWEST_COST_WITH_MIN_ROAS',
roasAverageFloor: 2.5,
},
});client.ad_campaigns.update_ad_set(
ad_set_id="AD_SET_ID",
platform="facebook",
bid_strategy="LOWEST_COST_WITH_MIN_ROAS",
roas_average_floor=2.5,
)curl -X PUT "https://zernio.com/api/v1/ads/ad-sets/AD_SET_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "facebook",
"bidStrategy": "LOWEST_COST_WITH_MIN_ROAS",
"roasAverageFloor": 2.5
}'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} now 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.
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' },
},
});client.ad_campaigns.update_ad_campaign(
campaign_id="CAMPAIGN_ID",
platform="facebook",
budget={"amount": 250, "type": "daily"},
)curl -X PUT "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "facebook",
"budget": { "amount": 250, "type": "daily" }
}'Update an ABO ad-set (budget and/or status)
PUT /v1/ads/ad-sets/{adSetId} when budgetLevel === 'adset'. Supply
budget and/or status, at least one is required. Returns 409 with
code: "BUDGET_LEVEL_MISMATCH" if the parent campaign is CBO.
await zernio.adcampaigns.updateAdSet({
path: { adSetId: 'ADSET_ID' },
body: {
platform: 'facebook',
budget: { amount: 75, type: 'daily' },
status: 'active',
},
});client.ad_campaigns.update_ad_set(
ad_set_id="ADSET_ID",
platform="facebook",
budget={"amount": 75, "type": "daily"},
status="active",
)curl -X PUT "https://zernio.com/api/v1/ads/ad-sets/ADSET_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "facebook",
"budget": { "amount": 75, "type": "daily" },
"status": "active"
}'For status-only toggles, the symmetric PUT /v1/ads/ad-sets/{adSetId}/status
mirrors /v1/ads/campaigns/{campaignId}/status.
Pause / resume a campaign (single or bulk)
Single campaign, cascades to all ad sets and ads:
await zernio.adcampaigns.updateAdCampaignStatus({
path: { campaignId: 'CAMPAIGN_ID' },
body: { status: 'paused', platform: 'facebook' },
});client.ad_campaigns.update_ad_campaign_status(
campaign_id="CAMPAIGN_ID",
status="paused",
platform="facebook",
)curl -X PUT "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/status" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "paused", "platform": "facebook" }'Bulk (up to 50 campaigns), each row's result is reported independently so one failure doesn't fail the whole batch:
await zernio.adcampaigns.bulkUpdateAdCampaignStatus({
body: {
status: 'paused',
campaigns: [
{ platformCampaignId: '1202...', platform: 'facebook' },
{ platformCampaignId: '1203...', platform: 'facebook' },
],
},
});client.ad_campaigns.bulk_update_ad_campaign_status(
status="paused",
campaigns=[
{"platformCampaignId": "1202...", "platform": "facebook"},
{"platformCampaignId": "1203...", "platform": "facebook"},
],
)curl -X POST "https://zernio.com/api/v1/ads/campaigns/bulk-status" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "paused",
"campaigns": [
{ "platformCampaignId": "1202...", "platform": "facebook" },
{ "platformCampaignId": "1203...", "platform": "facebook" }
]
}'Duplicate a campaign
POST /v1/ads/campaigns/{campaignId}/duplicate wraps Meta's native
POST /{campaign-id}/copies endpoint. Defaults to deepCopy: true +
statusOption: "PAUSED" so the copy is created with the full hierarchy but
doesn't start delivering until you activate it. Zernio triggers a sync
discovery after the copy so the new hierarchy materializes in /ads/tree
within seconds (set syncAfter: false to skip).
const result = await zernio.adcampaigns.duplicateAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: {
platform: 'facebook',
deepCopy: true,
statusOption: 'PAUSED',
renameStrategy: 'DEEP_RENAME',
renameSuffix: ' (copy)',
},
});result = client.ad_campaigns.duplicate_ad_campaign(
campaign_id="CAMPAIGN_ID",
platform="facebook",
deep_copy=True,
status_option="PAUSED",
rename_strategy="DEEP_RENAME",
rename_suffix=" (copy)",
)curl -X POST "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID/duplicate" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "facebook",
"deepCopy": true,
"statusOption": "PAUSED",
"renameStrategy": "DEEP_RENAME",
"renameSuffix": " (copy)"
}'Returns { copiedCampaignId, discovery, raw }. For hierarchies with > ~2
objects Meta requires async-batch mode; the synchronous path will return the
exact Meta error message so you can fall back to async in your client.
Delete a campaign
DELETE /v1/ads/campaigns/{campaignId} cascades on Meta's side (removes all
ad sets + ads) and marks local Ad docs as cancelled in the same pass.
The route requires platform in the body so we know which platform's API
to call.
await zernio.adcampaigns.deleteAdCampaign({
path: { campaignId: 'CAMPAIGN_ID' },
body: { platform: 'facebook' },
});client.ad_campaigns.delete_ad_campaign(
campaign_id="CAMPAIGN_ID",
platform="facebook",
)curl -X DELETE "https://zernio.com/api/v1/ads/campaigns/CAMPAIGN_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "platform": "facebook" }'Multi-creative campaigns (creative testing)
For creative A/B testing, don't spin up one campaign per creative, that fragments the budget across parallel ad sets and none of them exit Meta's learning phase (~50 conversions/week required). Instead, pass creatives[] on POST /v1/ads/create to get 1 campaign → 1 ad set → N ads sharing the same budget, targeting, and schedule. Meta's delivery algorithm allocates budget across the creatives inside the single ad set.
const result = await zernio.ads.createStandaloneAd({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
name: 'Spring Launch',
goal: 'traffic',
budgetAmount: 100,
budgetType: 'daily',
countries: ['AR'],
ageMin: 25,
ageMax: 45,
creatives: [
{ headline: 'Spring - 30% off', body: 'Limited time.', imageUrl: 'https://cdn.example.com/a.jpg', linkUrl: 'https://example.com/a', callToAction: 'SHOP_NOW' },
{ headline: 'Just for you', body: 'Curated picks.', imageUrl: 'https://cdn.example.com/b.jpg', linkUrl: 'https://example.com/b', callToAction: 'SHOP_NOW' },
{ headline: 'Free shipping', body: 'This week only.', imageUrl: 'https://cdn.example.com/c.jpg', linkUrl: 'https://example.com/c', callToAction: 'SHOP_NOW' },
],
},
});result = client.ads.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
name="Spring Launch",
goal="traffic",
budget_amount=100,
budget_type="daily",
countries=["AR"],
age_min=25,
age_max=45,
creatives=[
{"headline": "Spring - 30% off", "body": "Limited time.", "imageUrl": "https://cdn.example.com/a.jpg", "linkUrl": "https://example.com/a", "callToAction": "SHOP_NOW"},
{"headline": "Just for you", "body": "Curated picks.", "imageUrl": "https://cdn.example.com/b.jpg", "linkUrl": "https://example.com/b", "callToAction": "SHOP_NOW"},
{"headline": "Free shipping", "body": "This week only.", "imageUrl": "https://cdn.example.com/c.jpg", "linkUrl": "https://example.com/c", "callToAction": "SHOP_NOW"},
],
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"name": "Spring Launch",
"goal": "traffic",
"budgetAmount": 100,
"budgetType": "daily",
"countries": ["AR"],
"ageMin": 25,
"ageMax": 45,
"creatives": [
{ "headline": "Spring - 30% off", "body": "Limited time.", "imageUrl": "https://cdn.example.com/a.jpg", "linkUrl": "https://example.com/a", "callToAction": "SHOP_NOW" },
{ "headline": "Just for you", "body": "Curated picks.", "imageUrl": "https://cdn.example.com/b.jpg", "linkUrl": "https://example.com/b", "callToAction": "SHOP_NOW" },
{ "headline": "Free shipping", "body": "This week only.", "imageUrl": "https://cdn.example.com/c.jpg", "linkUrl": "https://example.com/c", "callToAction": "SHOP_NOW" }
]
}'Returns { ads: [...], platformCampaignId, platformAdSetId, message }, N Ad documents sharing the same platformCampaignId + platformAdSetId. Each ad's name is "<name> #N" (e.g. "Spring Launch #1", "Spring Launch #2"). To add more creatives later without spinning up a new campaign, use the attach shape below.
Meta-only today. Non-Meta platforms return 400.
Conversion campaigns and promoted objects
Optimization goals that point at a specific event/page/app need a promotedObject on the ad set. Without it Meta rejects the ad-set create with error_subcode: 1815430 ("Please select a promoted object for your ad set."). Zernio enforces this upfront so you get a clean 400 invalid_request_error with param: "promotedObject" instead of a passthrough Meta error.
goal | Meta optimization | Required promotedObject fields |
|---|---|---|
conversions | OFFSITE_CONVERSIONS | pixelId + customEventType |
app_promotion | APP_INSTALLS | applicationId + objectStoreUrl |
lead_generation | LEAD_GENERATION | pageId (auto-filled from the connected Page when omitted) |
customEventType accepts Meta's standard event names: PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART, INITIATE_CHECKOUT, ADD_PAYMENT_INFO, SUBSCRIBE, START_TRIAL, VIEW_CONTENT, SEARCH, CONTACT, SUBMIT_APPLICATION, SCHEDULE. Custom-conversion-driven optimization is supported via customConversionId instead of (or alongside) customEventType.
const result = await zernio.ads.createStandaloneAd({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
name: 'Spring sale - Purchase optimization',
goal: 'conversions',
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'],
promotedObject: {
pixelId: '1729525464415281',
customEventType: 'PURCHASE',
},
},
});result = client.ads.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
name="Spring sale - Purchase optimization",
goal="conversions",
budget_amount=75,
budget_type="daily",
headline="Spring Sale - 30% off",
body="Limited time. Upgrade today.",
image_url="https://cdn.example.com/spring.jpg",
call_to_action="SHOP_NOW",
link_url="https://example.com/spring",
countries=["US"],
promoted_object={
"pixelId": "1729525464415281",
"customEventType": "PURCHASE",
},
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"name": "Spring sale - Purchase optimization",
"goal": "conversions",
"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"],
"promotedObject": {
"pixelId": "1729525464415281",
"customEventType": "PURCHASE"
}
}'Need a pixel ID? Use zernio.trackingtags.listTrackingTags() to list the pixels a connected ad account can see (or zernio.trackingtags.createTrackingTag() to make one) — see Meta Pixels below. zernio.ads.listConversionDestinations() returns the same set, in case you're already wiring that.
Other Meta-specific knobs on promotedObject:
| Field | When you'd use it |
|---|---|
customConversionId | Optimising against a Custom Conversion (instead of a standard event). Pair with pixelId. |
productCatalogId + productSetId | Catalog Ads / Advantage+ Shopping campaigns. |
applicationId + objectStoreUrl | App-install / app-engagement campaigns (goal: "app_promotion"). |
Attach a creative to an existing ad set
To iterate on creatives after a campaign is already running, pass adSetId on POST /v1/ads/create with a single creative. One new ad gets attached to the existing ad set, budget, targeting, schedule, and goal are inherited from the ad set on Meta's side. No new campaign is created.
const result = await zernio.ads.createStandaloneAd({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
adSetId: '120250000000000000',
name: 'Spring Launch #4',
headline: 'One more angle',
body: 'Social proof variant.',
imageUrl: 'https://cdn.example.com/d.jpg',
linkUrl: 'https://example.com/d',
callToAction: 'SHOP_NOW',
},
});result = client.ads.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
ad_set_id="120250000000000000",
name="Spring Launch #4",
headline="One more angle",
body="Social proof variant.",
image_url="https://cdn.example.com/d.jpg",
link_url="https://example.com/d",
call_to_action="SHOP_NOW",
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"adSetId": "120250000000000000",
"name": "Spring Launch #4",
"headline": "One more angle",
"body": "Social proof variant.",
"imageUrl": "https://cdn.example.com/d.jpg",
"linkUrl": "https://example.com/d",
"callToAction": "SHOP_NOW"
}'Returns { ad, message }. Useful for weekly creative drops onto a stable ad set that Meta has already optimised on.
Video creatives
All three shapes (standalone, multi-creative, attach) accept video. Replace imageUrl with a video object carrying the video URL and a still-image thumbnail:
const result = await zernio.ads.createStandaloneAd({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
name: 'Spring launch video',
goal: 'video_views',
budgetAmount: 75,
budgetType: 'daily',
headline: 'Spring drop is live',
body: 'See it in action.',
video: {
url: 'https://cdn.example.com/spring.mp4',
thumbnailUrl: 'https://cdn.example.com/spring-poster.jpg',
},
callToAction: 'SHOP_NOW',
linkUrl: 'https://example.com/spring',
},
});result = client.ads.create_standalone_ad(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
name="Spring launch video",
goal="video_views",
budget_amount=75,
budget_type="daily",
headline="Spring drop is live",
body="See it in action.",
video={
"url": "https://cdn.example.com/spring.mp4",
"thumbnailUrl": "https://cdn.example.com/spring-poster.jpg",
},
call_to_action="SHOP_NOW",
link_url="https://example.com/spring",
)curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"name": "Spring launch video",
"goal": "video_views",
"budgetAmount": 75,
"budgetType": "daily",
"headline": "Spring drop is live",
"body": "See it in action.",
"video": {
"url": "https://cdn.example.com/spring.mp4",
"thumbnailUrl": "https://cdn.example.com/spring-poster.jpg"
},
"callToAction": "SHOP_NOW",
"linkUrl": "https://example.com/spring"
}'For multi-creative, put video: { url, thumbnailUrl } on each entry of creatives[] instead of imageUrl. video and imageUrl are mutually exclusive per creative, supply exactly one.
Thumbnail is required. Meta rejects video ads without a still-image thumbnail. Pass a public image URL in video.thumbnailUrl (1200x628 or 1080x1080 recommended).
Sync upload, long-running. Zernio uploads the video to Meta via chunked transfer and blocks until Meta finishes transcoding (status.video_status === "ready"). Longer videos can take several minutes. The endpoint is configured with maxDuration = 800s on our side; set your HTTP client timeout to at least 15 minutes so it doesn't bail before Meta finishes. If transcoding hasn't completed within 10 minutes, the request fails with a platform_error.
Custom Audiences
Create a Lookalike Audience:
const audience = await zernio.adaudiences.createAdAudience({
body: {
accountId: 'acc_metaads_123',
adAccountId: 'act_1234567890',
type: 'lookalike',
name: 'LAL 1% of US customers',
sourceAudienceId: '6123456789',
country: 'US',
ratio: 0.01,
},
});audience = client.ad_audiences.create_ad_audience(
account_id="acc_metaads_123",
ad_account_id="act_1234567890",
type="lookalike",
name="LAL 1% of US customers",
source_audience_id="6123456789",
country="US",
ratio=0.01,
)curl -X POST "https://zernio.com/api/v1/ads/audiences" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_metaads_123",
"adAccountId": "act_1234567890",
"type": "lookalike",
"name": "LAL 1% of US customers",
"sourceAudienceId": "6123456789",
"country": "US",
"ratio": 0.01
}'Add users to a customer list (SHA-256 hashed automatically):
await zernio.adaudiences.addUsersToAdAudience({
path: { audienceId: 'AUDIENCE_ID' },
body: {
users: [
{ email: 'user@example.com' },
{ phone: '+14155551234' },
],
},
});client.ad_audiences.add_users_to_ad_audience(
audience_id="AUDIENCE_ID",
users=[
{"email": "user@example.com"},
{"phone": "+14155551234"},
],
)curl -X POST "https://zernio.com/api/v1/ads/audiences/AUDIENCE_ID/users" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"users": [
{ "email": "user@example.com" },
{ "phone": "+14155551234" }
]
}'Targeting
Use /v1/ads/interests?platform=metaads&q=devops to search interest IDs, and /v1/ads/targeting/search?type=city&q=Amsterdam&countryCode=NL to look up city / region keys.
| Field | Type | Description |
|---|---|---|
ageMin | number | Minimum age (13-65) |
ageMax | number | Maximum age (13-65) |
countries | string[] | ISO 3166-1 alpha-2 country codes. Defaults to ["US"] when no cities or regions are provided. |
cities | object[] | City-level targeting. Each entry: { key, radius?, distance_unit? } where key is from /v1/ads/targeting/search, and radius + distance_unit ("kilometer" or "mile") must be set together. |
regions | object[] | Region/state-level targeting. Each entry: { key } from /v1/ads/targeting/search?type=region. |
interests | object[] | { id, name } from /v1/ads/interests |
gender | string | "all", "male", or "female" (default "all") |
On /v1/ads/create, targeting fields are flat at the top level (ageMin, ageMax, countries, cities, regions, interests). On /v1/ads/boost, they live inside a targeting: { ... } object that's passed through to Meta as-is.
Looking up city / region keys
Meta's cities and regions use opaque IDs that aren't derivable from the name. Use the targeting search helper to resolve them:
// Find Meta's key for Amsterdam in NL
const { results } = await zernio.ads.searchAdTargetingLocations({
accountId: 'FB_ACCOUNT_ID',
q: 'Amsterdam',
type: 'city',
countryCode: 'NL',
});
// results[0].key → "2759794"
// Use the key on /v1/ads/create
await zernio.ads.createStandaloneAd({ body: {
accountId: 'FB_ACCOUNT_ID',
adAccountId: 'act_1234567890',
name: 'Amsterdam launch',
goal: 'traffic',
budgetAmount: 20,
budgetType: 'lifetime',
endDate: '2026-05-15T00:00:00Z',
headline: 'Hello Amsterdam',
body: 'Try us this week.',
linkUrl: 'https://example.com',
imageUrl: 'https://cdn.example.com/promo.jpg',
callToAction: 'LEARN_MORE',
cities: [{ key: '2759794', radius: 25, distance_unit: 'kilometer' }],
dsaBeneficiary: 'Acme BV',
dsaPayor: 'Acme BV',
}});results = client.ads.search_ad_targeting_locations(
account_id="FB_ACCOUNT_ID",
q="Amsterdam",
type="city",
country_code="NL",
)["results"]
# results[0]["key"] → "2759794"
client.ads.create_standalone_ad(
account_id="FB_ACCOUNT_ID",
ad_account_id="act_1234567890",
name="Amsterdam launch",
goal="traffic",
budget_amount=20,
budget_type="lifetime",
end_date="2026-05-15T00:00:00Z",
headline="Hello Amsterdam",
body="Try us this week.",
link_url="https://example.com",
image_url="https://cdn.example.com/promo.jpg",
call_to_action="LEARN_MORE",
cities=[{"key": "2759794", "radius": 25, "distance_unit": "kilometer"}],
dsa_beneficiary="Acme BV",
dsa_payor="Acme BV",
)# 1. Look up the city key
curl "https://zernio.com/api/v1/ads/targeting/search?accountId=FB_ACCOUNT_ID&q=Amsterdam&type=city&countryCode=NL" \
-H "Authorization: Bearer YOUR_API_KEY"
# 2. Use the key on /v1/ads/create
curl -X POST "https://zernio.com/api/v1/ads/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "FB_ACCOUNT_ID",
"adAccountId": "act_1234567890",
"name": "Amsterdam launch",
"goal": "traffic",
"budgetAmount": 20,
"budgetType": "lifetime",
"endDate": "2026-05-15T00:00:00Z",
"headline": "Hello Amsterdam",
"body": "Try us this week.",
"linkUrl": "https://example.com",
"imageUrl": "https://cdn.example.com/promo.jpg",
"callToAction": "LEARN_MORE",
"cities": [{ "key": "2759794", "radius": 25, "distance_unit": "kilometer" }],
"dsaBeneficiary": "Acme BV",
"dsaPayor": "Acme BV"
}'type accepts city, region, country, subcity, neighborhood, zip, metro_area, geo_market. Pass countryCode to disambiguate when the same name exists in multiple countries (e.g. there are several "Eindhoven"s globally).
Don't combine cities with the same countries, Meta returns a "locations overlap" error because the city is already inside the country boundary. Either drop the country, or scope countries to a different country than the cities you're targeting.
Click-to-WhatsApp ads
Create ads that, when tapped, open a WhatsApp conversation with your business instead of sending the user to a website. Zernio drives the full hierarchy (campaign → ad set → creative → ad) in a single call. The CTA is locked to WHATSAPP_MESSAGE and the destination is hard-coded to https://api.whatsapp.com/send, Meta resolves the actual WhatsApp number from the Page-to-WhatsApp pairing configured in your Page settings.
Before calling, the Facebook Page must already be paired with a verified WhatsApp Business number (Meta Business Manager → WhatsApp Accounts → connect a Page). When the Page isn't paired, Meta rejects the ad set with subcode 2446886 and Zernio surfaces a clean platform_error envelope.
const result = await zernio.ads.createCtwaAd({ body: {
accountId: 'FB_OR_IG_ACCOUNT_ID',
adAccountId: 'act_123456789',
name: 'Summer promo, WhatsApp',
headline: 'Chat with us on WhatsApp',
body: 'Get a personal quote in 2 minutes.',
imageUrl: 'https://cdn.example.com/promo.jpg',
budgetAmount: 20,
budgetType: 'daily',
currency: 'USD',
countries: ['US', 'ES'],
}});result = client.ads.create_ctwa_ad(
account_id="FB_OR_IG_ACCOUNT_ID",
ad_account_id="act_123456789",
name="Summer promo, WhatsApp",
headline="Chat with us on WhatsApp",
body="Get a personal quote in 2 minutes.",
image_url="https://cdn.example.com/promo.jpg",
budget_amount=20,
budget_type="daily",
currency="USD",
countries=["US", "ES"],
)curl -X POST "https://zernio.com/api/v1/ads/ctwa" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "FB_OR_IG_ACCOUNT_ID",
"adAccountId": "act_123456789",
"name": "Summer promo, WhatsApp",
"headline": "Chat with us on WhatsApp",
"body": "Get a personal quote in 2 minutes.",
"imageUrl": "https://cdn.example.com/promo.jpg",
"budgetAmount": 20,
"budgetType": "daily",
"currency": "USD",
"countries": ["US", "ES"]
}'To close the attribution loop on conversions that happen inside the WhatsApp thread, see Conversions API for Business Messaging on the WhatsApp page.
Meta Pixels
A Meta Pixel is the measurement primitive you install on a website, send events to, and target ads against. Zernio exposes it under the platform-neutral tracking tags API — on Meta, kind is pixel. Uses the Meta Ads account you already connected; no extra OAuth, no business_management permission. The accountId is the Meta ads SocialAccount (the one the Ads add-on connect flow creates), not a Facebook/Instagram posting account; get your act_... ad account IDs from zernio.ads.listAdAccounts().
Create a pixel
const { data } = await zernio.trackingtags.createTrackingTag({
path: { accountId: 'ACCOUNT_ID' },
body: { adAccountId: 'act_1234567890', name: 'My Website Pixel' },
});
console.log(data.tag.id); // new pixel ID — use it in promotedObject.pixelId, audiences, CAPI
console.log(data.tag.code); // the base-code snippet to install on the siteresult = client.tracking_tags.create_tracking_tag(
account_id="ACCOUNT_ID",
ad_account_id="act_1234567890",
name="My Website Pixel",
)
print(result.tag.id) # new pixel ID
print(result.tag.code) # the install snippetcurl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"adAccountId":"act_1234567890","name":"My Website Pixel"}'The response is the new tag, including code — the base-code snippet to drop on the site. Creating a pixel doesn't install it: install code, or skip the snippet entirely and send events server-side via the Conversions API (a pixel works as a CAPI destination immediately). installed is derived from lastFiredTime (null = it has never fired).
List, fetch, and rename pixels
// Every pixel the connected ad account can see (pass query.adAccountId to scope to one)
const { data: list } = await zernio.trackingtags.listTrackingTags({
path: { accountId: 'ACCOUNT_ID' },
});
// One pixel — includes its install code, lastFiredTime, ownerBusinessId
const { data: one } = await zernio.trackingtags.getTrackingTag({
path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' },
});
// Rename, or toggle Advanced Matching / first-party cookies / data-use
await zernio.trackingtags.updateTrackingTag({
path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' },
body: { name: 'Website Pixel (renamed)', enableAutomaticMatching: true },
});# Every pixel the connected ad account can see (pass ad_account_id to scope to one)
listing = client.tracking_tags.list_tracking_tags(account_id="ACCOUNT_ID")
# One pixel — includes its install code, last_fired_time, owner_business_id
one = client.tracking_tags.get_tracking_tag(
account_id="ACCOUNT_ID", tag_id="1729525464415281"
)
# Rename, or toggle Advanced Matching / first-party cookies / data-use
client.tracking_tags.update_tracking_tag(
account_id="ACCOUNT_ID",
tag_id="1729525464415281",
name="Website Pixel (renamed)",
enable_automatic_matching=True,
)curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags" \
-H "Authorization: Bearer YOUR_API_KEY"
curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281" \
-H "Authorization: Bearer YOUR_API_KEY"
curl -X PATCH "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Website Pixel (renamed)","enableAutomaticMatching":true}'Share a pixel with another ad account
By default a pixel only works in the ad account it was created on. Share it with others so their campaigns and audiences can use it (listTrackingTagSharedAccounts and removeTrackingTagSharedAccount round out the set):
await zernio.trackingtags.addTrackingTagSharedAccount({
path: { accountId: 'ACCOUNT_ID', tagId: '1729525464415281' },
body: { adAccountId: 'act_9876543210' },
});client.tracking_tags.add_tracking_tag_shared_account(
account_id="ACCOUNT_ID",
tag_id="1729525464415281",
ad_account_id="act_9876543210",
)curl -X POST "https://zernio.com/api/v1/accounts/ACCOUNT_ID/tracking-tags/1729525464415281/shared-accounts" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"adAccountId":"act_9876543210"}'For aggregated event counts, call zernio.trackingtags.getTrackingTagStats(...) — aggregation is event by default; also host, url, device_type, and more.
Things to know about Meta Pixels:
- Not idempotent. Each create call makes a new pixel — don't retry blindly on timeout.
- One pixel per ad account on create.
POST .../tracking-tagsreturns a 400 (A pixel already exists for this account) if that ad account already has one. A Business Manager can own many pixels, just not two on the same ad account via this call. - No delete. Meta has no API to delete a pixel. To stop using one, unshare it (
DELETE .../shared-accounts) or disable it in Events Manager. - Personal ad accounts can't share. A pixel created on an ad account that isn't owned by a Business Manager comes back with
ownerBusinessId: nulland can't be shared with other ad accounts (Meta rejects the share). Claim the ad account into a Business Manager first. - Not exposed (needs
business_management): sharing a pixel with a partner/agency business, and assigning system users to a pixel.
Conversions API
Send offline conversion events (deal closed, lead qualified, trial converted) back to Meta via the Graph API events endpoint. Zernio uses the Meta Ads account you already connected, no additional OAuth, no pixel-scoped CAPI token to paste. PII is SHA-256 hashed server-side per Meta's spec before anything leaves your server.
Discover available pixels
const { data } = await zernio.ads.listConversionDestinations({
path: { accountId: 'ACCOUNT_ID' },
});data = client.ads.list_conversion_destinations(account_id="ACCOUNT_ID")curl "https://zernio.com/api/v1/accounts/ACCOUNT_ID/conversion-destinations" \
-H "Authorization: Bearer YOUR_API_KEY"Returns every pixel accessible to the connected ad accounts. Use the returned id as destinationId on the send call.
Send a conversion event
const result = await zernio.ads.sendConversions({ body: {
accountId: 'ACCOUNT_ID',
destinationId: '1729525464415281', // pixel ID
events: [{
eventName: 'Lead',
eventTime: Math.floor(Date.now() / 1000),
eventId: 'order_abc_123', // dedup key, must match pixel event if dual-tracking
value: 42.50,
currency: 'USD',
actionSource: 'crm',
user: {
email: 'customer@example.com',
phone: '+14155551234',
firstName: 'Jane',
lastName: 'Doe',
country: 'US',
},
}],
}});result = client.ads.send_conversions(
account_id="ACCOUNT_ID",
destination_id="1729525464415281",
events=[{
"eventName": "Lead",
"eventTime": int(time.time()),
"eventId": "order_abc_123",
"value": 42.50,
"currency": "USD",
"actionSource": "crm",
"user": {
"email": "customer@example.com",
"phone": "+14155551234",
"firstName": "Jane",
"lastName": "Doe",
"country": "US",
},
}],
)curl -X POST "https://zernio.com/api/v1/ads/conversions" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "ACCOUNT_ID",
"destinationId": "1729525464415281",
"events": [{
"eventName": "Lead",
"eventTime": 1744732800,
"eventId": "order_abc_123",
"value": 42.50,
"currency": "USD",
"actionSource": "crm",
"user": {
"email": "customer@example.com",
"phone": "+14155551234",
"firstName": "Jane",
"lastName": "Doe",
"country": "US"
}
}]
}'Standard event names
Purchase, Lead, CompleteRegistration, AddToCart, InitiateCheckout, AddPaymentInfo, Subscribe, StartTrial, ViewContent, Search, Contact, SubmitApplication, Schedule. Custom event names are accepted too.
Test mode
Pass testCode: "TEST12345" at the request root to route events to the Test Events tab in Meta Events Manager without affecting production pixel data.
Deduplication
Pass a stable eventId on every event. Meta dedupes against your pixel within a 48-hour window when eventId matches. Missing or inconsistent eventId between pixel and CAPI double-counts conversions, the #1 cause of inflated reports.
Batching
Up to 1,000 events per request (Zernio chunks larger batches automatically). Meta rejects the entire batch if any event is malformed, the response includes failures[] with per-event error detail and a traceId you can look up in Meta support.
Media Requirements
| Type | Format | Max Size | Notes |
|---|---|---|---|
| Feed Image | JPEG, PNG | 30 MB | 1080x1080 or 1200x628 recommended |
| Reels Video | MP4, MOV | 4 GB | 9:16 vertical, max 90 sec |
| Story | JPEG, PNG, MP4 | 30 MB / 4 GB | 9:16 vertical |
| Carousel | JPEG, PNG, MP4 | 30 MB/card | 2-10 cards |