Creating ad campaigns with the Meta Marketing API
The Marketing API builds an ad from four layers: a campaign (the objective, e.g. OUTCOME_TRAFFIC), an ad set (audience, budget, schedule), a creative (image, copy, link) and the ad itself. Budgets are integers in the currency's smallest unit, special_ad_categories is required on every campaign, and the safe pattern is to create everything PAUSED and activate after review.
Updated:
In short: how do you create a campaign via the API?
The order is fixed: pick an ad account (act_...), search and size your audience for free, then POST /act_{id}/campaigns (objective and special_ad_categories), /adsets (targeting, budget, optimization), /adimages and /adcreatives (image and copy), and finally /ads. Create each object PAUSED, and once reviewed switch all three levels to ACTIVE.
Permissions: ads_read for reporting, ads_management for creating and managing. Managing other businesses' ad accounts requires Advanced Access for ads_management — in other words, App Review.
The object hierarchy: what lives where
| Level | What it controls | Endpoint | SDK method |
|---|---|---|---|
| Ad account | Currency, billing, access | act_{id} | ads.ad_accounts |
| Campaign | One objective, special_ad_categories, optional shared budget | POST /act_{id}/campaigns | create_campaign |
| Ad set | Audience, budget, schedule, optimization goal and billing event | POST /act_{id}/adsets | create_adset |
| Image | The hash a creative references | POST /act_{id}/adimages | upload_image |
| Creative | Image or video, copy, link, CTA; immutable once created | POST /act_{id}/adcreatives | create_creative |
| Ad | Ties an ad set to a creative | POST /act_{id}/ads | create_ad |
All ads in one ad set share its targeting and optimization goal. Creatives can't be edited — create a new one and attach it to the ad.
Campaign objectives
Since Marketing API v17.0, new campaigns can only use the simplified OUTCOME objectives.
| Objective | Use it for |
|---|---|
OUTCOME_AWARENESS | Reaching as many people as possible, brand recall |
OUTCOME_TRAFFIC | Visits to a website, app or profile |
OUTCOME_ENGAGEMENT | Post engagement, video views, messages |
OUTCOME_LEADS | Lead forms and other inquiries |
OUTCOME_SALES | Sales and conversions |
OUTCOME_APP_PROMOTION | App installs and in-app actions |
Legacy objectives such as LINK_CLICKS, CONVERSIONS or REACH can't be used for new campaigns; existing campaigns created with them keep running.
What unit is the budget in?
daily_budget and lifetime_budget are integers in the ad account's currency, expressed in its smallest unit (Meta calls this the offset). For offset-100 currencies (USD, EUR, RUB and most others) a $20 daily budget is sent as 2000. For offset-1 currencies (JPY, KRW, IDR, VND, CLP, COP, CRC, HUF, ISK, PYG, TWD) you send the base-unit value.
Always compute the budget in the account's own currency (account.currency) — not every local currency is supported; for example, the Uzbek som (UZS) isn't on Meta's currency list.
A budget lives either on the campaign (Meta distributes it across ad sets) or on the ad set — never both. lifetime_budget requires an end_time; daily_budget is for ad sets running longer than 24 hours.
In the Socialift SDK, daily_budget_major=20 is multiplied by CURRENCY_OFFSET (100) and sent as 2000. That's correct for offset-100 currencies; if the account uses an offset-1 currency, divide your value by 100.
special_ad_categories is required on every campaign
Meta requires every new or edited campaign to declare whether it falls into a Special Ad Category. For regular ads you send an empty list ([]) — the SDK's create_campaign does this by default.
The categories are HOUSING, EMPLOYMENT, FINANCIAL_PRODUCTS_SERVICES (which replaced the old CREDIT value) and ISSUES_ELECTIONS_POLITICS. Once you pick a category, special_ad_category_country becomes required too.
Targeting is restricted in these categories: for housing, employment and financial ads aimed at the US, Canada or Europe, age is generally fixed at 18–65+, gender can't be chosen, location radius is at least 25 km (15 miles) and lookalike audiences are unavailable.
Launching a campaign from scratch in 8 steps
Pick the ad account
ads.ad_accountsreturns the accounts the user can access, with theiract_...ID and currency. Budgets are computed in that currency.Search interests and locations
search_interests(type=adinterest) returnsid,name,pathand audience size bounds.search_locations(type=adgeolocation) returns thekeyused in targeting. Store IDs, not names — Meta may rename interests.Build the targeting spec
At least one country is required unless you use a Custom Audience.
age_mincan't go below 13 andage_maxcan't exceed 65;gendersis 1 for men, 2 for women, omitted for all. City radius is 17–80 km (10–50 miles).Estimate the audience without spending
GET /act_{id}/delivery_estimatereturns a monthly audience range (estimate_mau_lower_bound,estimate_mau_upper_bound) andestimate_dau. Ifestimate_readyis false, ask again shortly.Create the campaign
Objective (
OUTCOME_*),special_ad_categoriesandstatus=PAUSED.Create the ad set
Targeting, budget,
optimization_goal(e.g.LINK_CLICKS),billing_event(e.g.IMPRESSIONS) andPAUSEDagain.Image, creative and ad
Upload the image to
/adimagesto get ahash, reference it inside the creative'sobject_story_spec(Page, link, copy, CTA), and pass{"creative_id": ...}when creating the ad.Review, then activate
Check the preview, then set the campaign, ad set and ad to
ACTIVE. Ads go through review first — watcheffective_statusforPENDING_REVIEWorDISAPPROVED.
Python: find and size the audience
Nothing gets created and nothing is spent — search and estimation only.
import asyncio, os
from socialift import Ads
async def main() -> None:
token = os.environ["META_USER_TOKEN"]
async with Ads(app_secret=os.environ["FACEBOOK_APP_SECRET"]) as ads:
account = (await ads.ad_accounts(token))[0]
print(account.id, account.currency)
interests = await ads.search_interests(token, "fitness", limit=5)
cities = await ads.search_locations(token, "Austin", country_code="US")
print([c["key"] for c in cities]) # for cities=[...]
targeting = ads.build_targeting(
countries=["US"],
age_min=18,
interests=[{"id": i["id"], "name": i["name"]} for i in interests[:3]],
) # advantage_audience=True by default
estimate = await ads.delivery_estimate(token, ad_account_id=account.id, targeting=targeting)
print(estimate.get("estimate_mau_lower_bound"), estimate.get("estimate_mau_upper_bound"))
asyncio.run(main())Entries in interests are combined with OR. For "A AND B", use the flexible_spec parameter of build_targeting.
Meta never returns an exact number, only a range. If it's too narrow, drop some interests or keep Advantage+ audience enabled.
Python: campaign → ad set → creative → ad
In the SDK status defaults to PAUSED — no call starts spending on its own.
from socialift import Ads
async def launch(ads: Ads, token: str, account_id: str, page_id: str, targeting: dict) -> str:
campaign = await ads.create_campaign(
token, ad_account_id=account_id, name="Fall sale",
objective="OUTCOME_TRAFFIC", special_ad_categories=[],
)
adset = await ads.create_adset(
token, ad_account_id=account_id, name="US 18+", campaign_id=campaign["id"],
targeting=targeting, optimization_goal="LINK_CLICKS", billing_event="IMPRESSIONS",
daily_budget_major=20, # USD account: sent as 2000 cents
)
with open("banner.jpg", "rb") as f:
image = await ads.upload_image(token, ad_account_id=account_id, image=f.read(), filename="banner.jpg")
creative = await ads.create_creative(
token, ad_account_id=account_id, name="Banner A", page_id=page_id,
link="https://example.com", message="Fall deals", image_hash=image["hash"],
)
ad = await ads.create_ad(
token, ad_account_id=account_id, name="Banner A",
adset_id=adset["id"], creative_id=creative["id"],
)
return ad["id"]For the ad to deliver, all three levels — campaign, ad set and ad — must be ACTIVE: if a parent is paused, the ad's effective_status reads CAMPAIGN_PAUSED or ADSET_PAUSED. Activate with await ads.set_status(token, node_id, "ACTIVE") for each level.
The most reliable kill switch is the campaign: set it to PAUSED and nothing beneath it delivers.
To run the ad under an Instagram account's identity, pass instagram_user_id to create_creative — Meta requires it in place of the deprecated instagram_actor_id.
Advantage+ audience must be explicit since v23.0
Advantage+ audience lets Meta's delivery system look beyond the audience you defined to find people likely to convert. In the API it's targeting.targeting_automation.advantage_audience: 1 on, 0 off.
Starting with Graph API v23.0, creating a new ad set either defaults this flag to 1 or requires an explicit 1 or 0. Updating existing ad sets isn't affected.
When it's on, age isn't a hard limit: age_min accepts only 18–25 and age_max is fixed at 65. A narrower age band goes in as a suggestion via age_range. If you need strict control (legal restrictions, for example), call build_targeting(..., advantage_audience=False) and pass age_min/age_max as hard bounds.
Permissions and Marketing API access tiers
ads_read covers ad reporting and the Insights API. ads_management reads and manages ad accounts and creates campaigns; it depends on pages_read_engagement and pages_show_list and requires Advanced Access for other businesses. Tech providers handling other businesses' data also need Access Verification.
The Marketing API has its own tiers: Limited Access (formerly Standard) comes automatically with the product and is heavily rate-limited per ad account — meant for development. Full Access (formerly Advanced) follows App Review and is lightly rate-limited. Meta's bar: at least 500 Marketing API calls in the last 15 days and an error rate under 15% across the last 500 calls.
Reading results with insights
Every level exposes /{object-id}/insights: ad account, campaign, ad set and ad.
summary = await ads.insights(token, campaign_id, since="2026-09-01", until="2026-09-16")
print(summary.spend, summary.impressions, summary.reach, summary.ctr)
rows = await ads.insights_rows(
token, account_id, level="adset", date_preset="last_7d", breakdowns=["age", "gender"]
)
for row in rows:
print(row.get("age"), row.get("gender"), row.get("spend"), row.get("cpc"))Without dates, insights returns lifetime totals (date_preset=maximum); a bare request to Meta typically returns the last 30 days.
insights_rows returns raw broken-down rows — this is where you learn which age group clicks cheapest. Not every breakdown combination is allowed: if you get an error, add them one at a time.
Pre-launch checklist
- Account currency and its offset are known; the budget is converted accordingly.
special_ad_categoriesis explicit ([]for regular ads).- Targeting includes at least one country or a Custom Audience.
- The
delivery_estimaterange fits the goal. - Campaign, ad set and ad were created
PAUSEDand reviewed. - Launch = all three levels
ACTIVE; stop = campaignPAUSED. effective_statusand spend are monitored regularly.
Frequently asked questions
Why is my budget 100 times too big or too small?
The Marketing API expects budgets in the currency's smallest unit. In USD, $20 is 2000; in an offset-1 currency like JPY, 2000 means ¥2,000. Check the ad account currency and send the value accordingly.
Can I still use the LINK_CLICKS objective?
Not for new campaigns: since v17.0 only OUTCOME_* objectives are accepted. LINK_CLICKS now lives on as an optimization_goal at the ad set level.
Can I check audience size without spending money?
Yes. The delivery_estimate endpoint returns an estimated monthly audience range and daily active users. Meta gives ranges, never exact counts.
My ad was created but isn't delivering — why?
Usually the campaign or ad set is still PAUSED, or the ad is in review (PENDING_REVIEW) or rejected (DISAPPROVED). Check the ad's effective_status.
Does ads_management need App Review?
Not for your own ad accounts and app role users — Standard (Limited) Access covers that. Managing other businesses' ad accounts requires Advanced Access, meaning App Review and Business Verification.
Can I omit special_ad_categories?
No, it's required. Send an empty list for regular ads; for housing, employment, financial or political topics, set the matching category and special_ad_category_country.