Ads
Run Facebook and Instagram campaigns from the API, with conversation ads, creative testing, reach and frequency buys, previews and live insights.
Meta Ads covers Facebook, Instagram, Messenger, Threads, Audience Network and WhatsApp Status placements. It is the most complete network in Pozy: every operation in the ads overview works here, plus several that only Meta offers.
#Before you start
On Meta's side you need:
- A Business portfolio with at least one ad account (
act_<number>). You can also create one through the API. - A Facebook Page. Ads run as the Page. Instagram placements also need an Instagram professional account linked to that Page.
- A pixel or dataset if the campaign optimizes for conversions. See Pixels and tags.
An Instagram account connected with Instagram Login never carries ads permissions. Ads calls made with its accountId return 422 with code linked_account_required. Connect a facebook or metaads account in the same profile and use that one.
#Connect
Call GET /v1/connect/facebook/ads (or /v1/connect/instagram/ads) with the profileId. Meta offers two login modes, picked with loginMode:
loginMode |
What it creates | Use it when |
|---|---|---|
classic (default) |
A metaads account linked to the profile's Facebook account. If that account already has the ads permissions, no consent screen appears. |
You already publish to the Page through Pozy. |
business |
A standalone metaads account with a system-user token. No Facebook posting account is created. |
You only run ads, or you want a token that does not depend on one person staying an admin. |
Either way, ads need a Page as the actor. With business login, pass pageId on the connect call or change the default Page later with PUT /v1/accounts/{accountId}/facebook-page.
A metaads account is for ads only. Page and Instagram insights need the organic Facebook or Instagram connection, which is a separate connected account.
#Create an ad account
POST /v1/ads/accounts creates a Meta ad account inside the customer's own Business portfolio. Find the portfolio with GET /v1/ads/businesses, then send accountId (an active metaads connection), businessId, name, currency and timezoneId. timezoneId is Meta's numeric timezone id, not an IANA name. The connection needs Business admin access.
A new ad account has no payment method. The customer adds one in Meta Ads Manager before anything delivers. The create is not idempotent: after a timeout, check the portfolio in Ads Manager before you try again.
#Create a campaign
POST /v1/ads/create builds a campaign, an ad set and an ad in one call. Every field sits at the top level, and the network is inferred from accountId.
curl -X POST https://api.pozy.ai/v1/ads/create \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: spring-sale-us-feed" \
-d '{
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"adAccountId": "act_1234567890",
"name": "Spring sale - US",
"goal": "traffic",
"budgetAmount": 40,
"budgetType": "daily",
"status": "PAUSED",
"headline": "30% off everything this week",
"body": "Free shipping on orders over $50.",
"imageUrl": "https://cdn.example.com/spring-1080.jpg",
"linkUrl": "https://shop.example.com/spring",
"callToAction": "SHOP_NOW",
"countries": ["US"],
"ageMin": 25,
"ageMax": 54
}'const res = await fetch("https://api.pozy.ai/v1/ads/create", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Idempotency-Key": "spring-sale-us-feed",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: "66b2e19d8c3f5a7e9d0b1c2d",
adAccountId: "act_1234567890",
name: "Spring sale - US",
goal: "traffic",
budgetAmount: 40,
budgetType: "daily",
status: "PAUSED",
headline: "30% off everything this week",
body: "Free shipping on orders over $50.",
imageUrl: "https://cdn.example.com/spring-1080.jpg",
linkUrl: "https://shop.example.com/spring",
callToAction: "SHOP_NOW",
countries: ["US"],
ageMin: 25,
ageMax: 54,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.ad.platformCampaignId, data.ad.reviewStatus);import os, requests
resp = requests.post(
"https://api.pozy.ai/v1/ads/create",
headers={
"Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
"Idempotency-Key": "spring-sale-us-feed",
},
json={
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"adAccountId": "act_1234567890",
"name": "Spring sale - US",
"goal": "traffic",
"budgetAmount": 40,
"budgetType": "daily",
"status": "PAUSED",
"headline": "30% off everything this week",
"body": "Free shipping on orders over $50.",
"imageUrl": "https://cdn.example.com/spring-1080.jpg",
"linkUrl": "https://shop.example.com/spring",
"callToAction": "SHOP_NOW",
"countries": ["US"],
"ageMin": 25,
"ageMax": 54,
},
timeout=60,
)
resp.raise_for_status()
print(resp.json()["ad"]["_id"]){
"ad": {
"_id": "66d4a1b2c3e4f5a6b7c8d9e2",
"name": "Spring sale - US",
"platform": "facebook",
"status": "paused",
"configuredStatus": "PAUSED",
"reviewStatus": "in_review",
"adType": "standalone",
"goal": "traffic",
"budget": { "amount": 40, "type": "daily" },
"platformAdAccountId": "act_1234567890",
"platformCampaignId": "120250000000000000",
"platformAdSetId": "120250000000000001",
"platformAdId": "120260000000000000",
"creative": { "creativeId": "120250000000000005", "pageId": "811889972008357" }
},
"message": "Ad created"
}Keep three ids from the response: _id for per-ad calls, platformCampaignId for campaign calls and platformAdSetId for ad set calls. creative.creativeId can be reused later as existingCreativeId.
Without status: "PAUSED" the ad is created active and goes straight into Meta's review. To provision a campaign container first and add ad sets to it later, use POST /v1/ads/campaigns, then pass the campaign id as existingCampaignId on each create. POST /v1/ads/campaigns creates the campaign paused unless you send status: "ACTIVE".
#Check a request without spending
Add validateOnly: true to run the whole tree through Meta's own validator. Nothing is created. You get 200 with one result per node (validated or skipped) and Meta's error verbatim when something is wrong. Meta can only validate an ad set against a real campaign, so on a fresh tree the ad set reports skipped unless you pass existingCampaignId. Dry runs work on the single-ad shape only, not with creatives[], adSetId or reserved buying.
#Goals
goal picks the Meta objective and a sensible optimization goal for it.
goal |
Meta objective | Needs |
|---|---|---|
traffic |
Traffic | linkUrl |
engagement |
Engagement | |
awareness |
Awareness | |
video_views |
Engagement, ThruPlay | A video creative |
lead_generation |
Leads, instant form | leadGenFormId (Lead forms) |
lead_conversion |
Leads, website | promotedObject with a pixel |
conversions |
Sales | promotedObject.pixelId and customEventType |
app_promotion |
App promotion | promotedObject.applicationId and objectStoreUrl |
catalog_sales |
Sales, catalog | promotedObject.productCatalogId / productSetId (Catalogs) |
page_likes, page_visits |
Engagement | A Page |
optimizationGoal, billingEvent and promotedObject override the defaults when you need Meta's exact vocabulary (for example LINK_CLICKS or OFFSITE_CONVERSIONS).
#Budgets and bidding
| Field | Values | Notes |
|---|---|---|
budgetAmount |
number | Whole units of the ad account currency. |
budgetType |
daily, lifetime |
A lifetime budget needs endDate. |
budgetLevel |
adset (default), campaign |
campaign is Meta's Advantage campaign budget (CBO). Read budgetLevel on the tree before you change a budget, because the update goes to whichever level owns it. |
startDate, endDate |
ISO 8601 | A value without an offset is read in the ad account's timezone. |
buyingType |
AUCTION (default), RESERVED |
RESERVED is a reach and frequency buy. |
Bid strategies go inside platformSpecificData on create:
bidStrategy |
Companion field | Behavior |
|---|---|---|
LOWEST_COST_WITHOUT_CAP (default) |
Meta spends the budget for the most results. | |
LOWEST_COST_WITH_BID_CAP |
bidAmount |
Hard ceiling per auction. |
COST_CAP |
bidAmount |
Target average cost per result. |
LOWEST_COST_WITH_MIN_ROAS |
roasAverageFloor |
Value-optimized sales campaigns only. 2.0 means 2x ROAS. |
platformSpecificData also takes dailyMinSpendTarget and lifetimeMinSpendTarget (reserve part of a campaign budget for one ad set), customerLifecycle (new customers only, or all) and adSchedule (dayparting rows of dayOfWeek, startHour, endHour, which need a lifetime budget).
Budget schedules raise a budget for a sale or a busy weekend: POST /v1/ads/high-demand-periods with exactly one of campaignId or adSetId, and GET /v1/ads/high-demand-periods to read them.
Value rule sets raise or lower the bid for segments worth more or less to you, without splitting them into separate ad sets. Create one with POST /v1/ads/value-rule-sets, then attach it with valueRuleSetId on the create or on PUT /v1/ads/ad-sets/{adSetId}. Meta allows 6 sets per ad account, 10 rules per set and 4 criteria per rule. An update replaces the whole set, so read it first and send every rule you want to keep.
#Creatives and formats
Exactly one media source per creative. Pick the shape:
| Shape | Fields | Notes |
|---|---|---|
| Single image | imageUrl |
JPEG or PNG, up to 30 MB. |
| Single video | video: { url, thumbnailUrl? } or video: { id } |
MP4 or MOV up to 4 GB. The create waits for Meta to finish processing, up to 10 minutes, so set your HTTP timeout above that. |
| Carousel | carouselCards (2 to 10) |
Each card has its own image and optional headline, description, link and button. optimizeCardOrder lets Meta reorder. |
| N ads in one ad set | creatives[] |
See creative testing. |
| Asset pool | dynamicCreative |
Up to several images, videos, bodies and titles that Meta recombines. |
| Per-placement assets | placementAssets |
A 9:16 asset on Stories and Reels and a 1:1 on Feed, in one ad. All images or all videos. 1 to 10 rules. |
| Several languages | translations + defaultLocale |
Up to 10 locales on one ad, so engagement stays on one post. Every locale needs its own headline, body and description. |
| Reuse a creative | existingCreativeId |
From an earlier create or POST /v1/ads/creatives. |
callToAction takes the values listed in the API reference, such as SHOP_NOW, LEARN_MORE, SIGN_UP and CONTACT_US. Media URLs must be public: Meta downloads them itself, and a hotlink-protected host fails.
#Advantage+ options
creativeFeaturesopts a creative in or out of Meta's individual Advantage+ creative enhancements, keyed by Meta's feature names. Anything you leave out is opted out.advantageAudience: 1lets Meta expand delivery beyond your targeting. Pozy sends0for you unless you set it.budgetLevel: "campaign"turns on Advantage campaign budget.promotionattaches an offer (PERCENTAGE_OFF,AMOUNT_OFF,FREE_SHIPPING,FREE_RETURN,PROMO_CODE) to the creative. Checkad.creative.promotionStatuson a later read to see whether Meta kept it.
#Conversation and call ads
POST /v1/ads/messaging creates an ad that opens a chat instead of a web page. destination picks where:
destination |
Opens in | Requires |
|---|---|---|
whatsapp |
The Page paired with a verified WhatsApp Business number. whatsappPhoneNumber picks one of several. |
|
messenger |
The Page's Messenger inbox | A Page |
instagram_direct |
Instagram Direct | An Instagram professional account linked to the Page |
The ad set is built with conversation optimization and the matching destination type. Useful extras:
welcomeMessagereplaces Meta's greeting and pre-fills the person's first message.workflowIdstarts one of your workflows in the conversation the click opens.existingPostIdorobjectStoryIdruns an organic post as the ad instead of a new creative.adSetIdadds creatives to a running conversation ad set without resetting its learning.
POST /v1/ads/call builds a call ad that dials phoneNumber (E.164). linkUrl must be a real web page, not a tel: link. POST /v1/ads/ctwa is the older click-to-WhatsApp endpoint; it still works, but new code should use /v1/ads/messaging with destination: "whatsapp".
#Creative testing
Send creatives[] instead of the single-creative fields to get one campaign, one ad set and N ads that share the budget. The ads are named <name> #1, <name> #2 and so on, and the response returns ads[] instead of ad. To add a variation later, call the create again with the ad set's id in adSetId and a single creative: it inherits the budget, targeting and schedule and keeps the learning Meta has done.
Rank variations with the per-ad metrics on GET /v1/ads/tree, or split one ad's results by the asset Meta served with the breakdowns image_asset, video_asset, body_asset and title_asset.
GET /v1/ads/studies lists the A/B tests and lift studies on the ad account.
#Partnership ads
Partnership ads run a creator's Instagram post under your ad account, with both handles on the ad. Ask a creator for permission with POST /v1/ads/partnership-permissions, track who has granted it with GET /v1/ads/partnership-permissions, and find their eligible posts with GET /v1/ads/partnership-content. This is in private beta, so the calls can return feature_not_available until it opens to your workspace.
#Reach and frequency
Reach and frequency buys lock a price for a fixed audience and frequency before you spend.
#Quote
POST /v1/ads/rf-predictions with the targeting, explicit placements, the window and either budgetAmount (Meta predicts reach) or reach (Meta predicts budget). frequencyCap is 1 to 90. Show the returned minimum and maximum budget and reach.
#Reserve
POST /v1/ads/rf-predictions/{predictionId}/reserve holds the inventory for about 14 days and returns a new prediction id. Release it with DELETE /v1/ads/rf-predictions/{predictionId}.
#Buy
POST /v1/ads/create with buyingType: "RESERVED" and rfPredictionId set to the reserved id. Send no budget or bid fields: they come from the reservation. Targeting and placements must match the quote.
#Previews
GET /v1/ads/{adId}/preview?formats=INSTAGRAM_STORY,MOBILE_FEED_STANDARD renders an existing ad. POST /v1/ads/preview renders a creative before it is on an ad, from existingCreativeId or a raw creativeSpec. Both return Meta's iframe HTML, one per format, up to 10 formats per call. Embed the snippet as it is and fetch it when the user opens the preview: the signed URL inside is short-lived.
#Ad comments
GET /v1/ads/{adId}/comments reads the comments on an ad, including dark posts that never appeared on the Page. An ad that runs on both Facebook and Instagram has two threads: pick one with placement=facebook or placement=instagram. Reply through the normal comments endpoints with the post id the response returns in meta.effectiveStoryId. Hiding and deleting through the ad endpoints is TikTok only and returns 501 on Meta.
#Manage running ads
| Job | Call |
|---|---|
| Pause or resume a campaign, ad set or ad | PUT /v1/ads/campaigns/{campaignId}/status, PUT /v1/ads/ad-sets/{adSetId}/status, PUT /v1/ads/{adId}/status with status (active or paused) and platform |
| Pause or resume up to 50 campaigns | POST /v1/ads/campaigns/bulk-status |
| Change an ad set budget, bid, schedule or optimization | PUT /v1/ads/ad-sets/{adSetId}, delivery settings inside platformSpecificData |
| Change a campaign budget (CBO) | PUT /v1/ads/campaigns/{campaignId} with budget |
| Change targeting | PUT /v1/ads/{adId} with targeting (applies to the ad's ad set) |
| Swap the creative | PUT /v1/ads/{adId} with creative. Meta creatives are immutable, so Pozy builds a new one and points the ad at it. The ad goes through review again. |
| Duplicate | POST /v1/ads/campaigns/{campaignId}/duplicate, /ad-sets/{adSetId}/duplicate, /{adId}/duplicate |
| Delete | DELETE on the campaign, ad set or ad. Deletes cascade down. |
Each switch is independent. Pausing a campaign stops everything under it, but resuming it only restarts children whose own switch is on. When a status is already what you asked for, nothing is written and the response says skipped: 1.
Duplicates default to a deep copy created paused (statusOption: "PAUSED"), so nothing spends until you activate it. renameStrategy, renamePrefix and renameSuffix name the copies, and the response maps each source id to its copy in adSetIdMap and adIdMap. A large campaign takes minutes because Meta copies one object at a time.
GET /v1/ads/ad-sets/{adSetId} reads the ad set live from Meta, including learning_stage_info.status (LEARNING, SUCCESS, FAIL, WAIVING). A missing status means unknown, not "out of learning".
#Reporting
| You want | Call | Cost to the account |
|---|---|---|
| Spend, CPC, CPM, conversions and ROAS per level | GET /v1/ads/tree, GET /v1/ads, GET /v1/ads/{adId} |
None, served from synced metrics |
| A daily series | GET /v1/ads/timeline |
None |
| One ad or campaign split by a dimension | GET /v1/ads/{adId}/analytics, GET /v1/ads/campaigns/{campaignId}/analytics with breakdowns |
One Meta call per dimension |
| Any Meta field, breakdown or filter | GET /v1/ads/insights with objectId, level, fields, breakdowns |
One live call |
| Long ranges or whole accounts | POST /v1/ads/insights/reports, then poll GET /v1/ads/insights/reports/{reportRunId} |
A Meta job |
Breakdown dimensions: age, gender, country, region, publisher_platform, platform_position, device_platform, impression_device, image_asset, video_asset, body_asset, title_asset.
Metrics on each node include spend, impressions, reach, clicks, ctr, cpc, cpm, conversions, actions, actionValues, purchaseValue and roas. Reach is de-duplicated within a day only, so never sum it across days. A freshly connected ad account can answer 202 with backfillPending: true while history loads: retry after the Retry-After delay.
When polling an async report, stop on Job Completed, Job Failed or Job Skipped. Start at 5 seconds and back off to a minute. More in Reporting.
#Regulated ads
specialAdCategoriesdeclares housing, employment, credit, financial products, politics or gambling ads. Meta then restricts targeting (no income or ZIP targeting).- Ads delivered in the EU need
dsaBeneficiaryanddsaPayorunless the ad account has defaults (GET /v1/ads/dsa-defaults). - Other regimes (Brazil, Thailand, India and more) use
regionalRegulatedCategoriesandregionalRegulationIdentities.
#Limits and errors
| Error | Cause | Fix |
|---|---|---|
422 linked_account_required |
The account is an Instagram Login account | Use a facebook or metaads account from the same profile. |
400 with type: "platform_error" |
Meta refused the request. Meta's code and subcode are in platformError. |
Read Meta's message: it usually names the field. |
400 naming two fields |
Two creative shapes that exclude each other | Send one media source per creative. |
409 BUDGET_LEVEL_MISMATCH |
You sent an ad set budget to a CBO campaign | Update the budget on the campaign instead. |
409 on a boost or create retry |
An identical request is still in flight | Wait, then read GET /v1/ads. Send an Idempotency-Key. |
502 video_thumbnail_unavailable |
Meta produced no poster for the video | Send thumbnailUrl. |
Locations overlap is the most common targeting error: do not send a city together with the country that contains it. See Targeting.
#Endpoints
Campaigns, ad sets, ads and reporting:
Meta-only tools:
Conversation ads and reach and frequency:
| Endpoint | What it does |
|---|---|
POST /v1/ads/messaging |
Create messaging ad |
POST /v1/ads/call |
Create Click-to-Call ad |
POST /v1/ads/ctwa |
Create CTWA ad (deprecated) |
POST /v1/ads/rf-predictions |
Create reach-frequency prediction |
GET /v1/ads/rf-predictions/{predictionId} |
Get reach-frequency prediction |
DELETE /v1/ads/rf-predictions/{predictionId} |
Cancel reach-frequency booking |
POST /v1/ads/rf-predictions/{predictionId}/reserve |
Reserve reach-frequency inventory |