Meta Marketing API — Reference Guide
A working reference for the Meta Marketing API surface Kanzen Ads and connected agents operate against — object hierarchy, regular ad operations, Collaborative Ads (CPAS), auth/rate-limit mechanics, and a ready-to-use cookbook. Pinned to API version v25.0.
Verify-don't-trust
0. Basics
Object hierarchy:
Business Manager (business_id)
└─ Ad Account act_{ad_account_id}
└─ Campaign → objective, budget (if CBO)
└─ Ad Set → targeting, budget (if ABO), schedule, bid, optimization
└─ Ad → links Ad Set + Creative
└─ Ad Creative → object_story_spec / object_story_id
├─ Pages (page_id) · Instagram (ig_user_id) · Pixels/Datasets · Product CatalogsAn ad account is always referenced with the act_ prefix (e.g. act_123456789).
Auth (summary — details in §C1): tokens are sent as a query/body param access_token=<TOKEN> (NOT a Bearer header — that's what the official doc guarantees). For server-to-server production use a System User token (no expiry). Core scopes: ads_management (write), ads_read (read), business_management, catalog_management.
Base call: GET/POST/DELETE https://graph.facebook.com/v25.0/{node-or-edge}?...&access_token=<TOKEN>
Part A — Regular Ops (non-CPAS)
Standard campaign/ad set/ad operations used for the majority of ads (non-collaborative). For CPAS, see Part B.
A1. Campaign
Doc: /reference/ad-campaign-group/ (internal node name is ad-campaign-group).
Key fields: name, objective, status (ACTIVE/PAUSED/DELETED/ARCHIVED), effective_status, special_ad_categories (required — [] / NONE unless it's a special category), bid_strategy, daily_budget/lifetime_budget (CBO only — see A3), spend_cap, promoted_object.
Objective (ODAX — current): OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_ENGAGEMENT, OUTCOME_TRAFFIC, OUTCOME_AWARENESS, OUTCOME_APP_PROMOTION. (Legacy CONVERSIONS/LINK_CLICKS/etc. still return on old campaigns, but can't be used to create new ones.)
# Create
curl -X POST -F 'name=Campaign X' -F 'objective=OUTCOME_TRAFFIC' \
-F 'status=PAUSED' -F 'special_ad_categories=[]' -F 'access_token=<TOKEN>' \
https://graph.facebook.com/v25.0/act_<AD_ACCOUNT_ID>/campaigns
# → {"id":"<CAMPAIGN_ID>"}
# Read
GET /v25.0/<CAMPAIGN_ID>?fields=id,name,objective,status,effective_status,daily_budget,lifetime_budget,bid_strategyA2. Ad Set
Doc: /reference/ad-campaign/ (internal node name is ad-campaign, but this is the Ad Set document).
Key fields: campaign_id (required), daily_budget/lifetime_budget (ABO only), billing_event, optimization_goal, bid_amount/bid_strategy, targeting, promoted_object, start_time/end_time, attribution_spec, status. optimization_goal × billing_event must be a valid combination for the campaign's objective (enforced server-side — ⚠️ VERIFY per objective).
curl -X POST -F 'name=Adset A' -F 'campaign_id=<CAMPAIGN_ID>' \
-F 'daily_budget=100000' -F 'billing_event=IMPRESSIONS' -F 'optimization_goal=OFFSITE_CONVERSIONS' \
-F 'targeting={"geo_locations":{"countries":["ID"]},"age_min":18,"age_max":65}' \
-F 'status=PAUSED' -F 'access_token=<TOKEN>' \
https://graph.facebook.com/v25.0/act_<AD_ACCOUNT_ID>/adsetsA3. Budget — CBO vs ABO, scale up/down, pause/resume
| Mode | Budget lives on | Behavior |
|---|---|---|
| CBO (Advantage campaign budget) | Campaign (daily_budget/lifetime_budget) | Meta distributes spend across ad sets automatically. Ad sets must NOT have their own budget. |
| ABO (ad set budget) | Ad Set (daily_budget/lifetime_budget) | Budget is fixed per ad set; Meta does not reallocate across ad sets. |
Budget is in minor currency units
daily_budget=100000 on an IDR account is not a safe assumption to hardcode — some currencies' minor-unit behavior differs (e.g. IDR/JPY). Always check GET /act_{id}?fields=currency first. USD example: 2000 = $20.00.- Scaling up/down: ALWAYS read the current value first, compute, then POST — never blind-write.
- CBO → POST to
{campaign_id}. ABO → POST to{adset_id}. - ">20% change resets the learning phase" is a practitioner heuristic, not an official API contract — don't hardcode it as a Meta rule. What's confirmed to reset learning: changing
targeting/optimization_goal/bid_strategy. - Daily budget can over-spend up to ~25% on high-opportunity days, balanced out weekly.
# SCALE UP +30% (CBO campaign) GET /v25.0/<CAMPAIGN_ID>?fields=daily_budget # → "10000" ($100) POST /v25.0/<CAMPAIGN_ID> -F 'daily_budget=13000' # $130 GET /v25.0/<CAMPAIGN_ID>?fields=daily_budget # readback # SCALE DOWN -30% (ABO adset) GET /v25.0/<ADSET_ID>?fields=daily_budget # → "5000" POST /v25.0/<ADSET_ID> -F 'daily_budget=3500' # PAUSE / RESUME (campaign or adset) POST /v25.0/<ADSET_ID> -F 'status=PAUSED' POST /v25.0/<ADSET_ID> -F 'status=ACTIVE' # ⚠️ If the parent campaign is PAUSED, resuming the ad set does not deliver — # effective_status stays CAMPAIGN_PAUSED until the campaign is resumed too.
A4. Ad & Ad Creative
Doc: /reference/adgroup/ (Ad), /reference/ad-creative/.
Ad: adset_id (required, immutable after create), creative ({"creative_id":"<ID>"} or inline), status (create only accepts ACTIVE/PAUSED, then auto-transitions to PENDING_REVIEW).
Creative — two ways:
- (a)
object_story_spec— creates a new post (usually dark/unpublished) at creative-creation time:page_id+link_data/video_data/photo_data+call_to_action+image_hash(pre-uploaded image) orpicture(URL). For IG placement useinstagram_user_id(NOT the oldinstagram_actor_id— deprecated Sept 2025). - (b)
object_story_id— uses an already-published post: format{page_id}_{post_id}. If a creative for that story id already exists, the API returns the existing creative_id (idempotent).
image_hash vs. attachment_hash
image_hash (top-level on AdCreative for image ads, and inside link_data for link/carousel ads). The literal field attachment_hash does not appear in the v25.0 reference — if older Kanzen Ads code uses it, treat as ⚠️ VERIFY (possibly an old API version or an internal naming). Advantage+/Dynamic creative uses asset_feed_spec (arrays of images/bodies/titles/CTAs that Meta combines automatically).A5. Getting the post ID
| Field (on AdCreative) | Meaning |
|---|---|
object_story_id | Only populated if the creative was made from an existing post. Made from object_story_spec → null. |
effective_object_story_id | The reliable field — always resolves to the real post id (organic or dark). Use this one. |
effective_instagram_media_id | IG equivalent (replaces the old effective_instagram_story_id). |
Post id format: {page_id}_{post_id}.
# Get the post id from an ad
GET /v25.0/<AD_ID>?fields=creative{id,object_story_id,effective_object_story_id,effective_instagram_media_id}
# → creative.effective_object_story_id = "123456789_987654321"
# List posts promotable from a Page (needs pages_manage_ads + ads_management + task ADVERTISE)
GET /v25.0/<PAGE_ID>/ads_posts?fields=id,message,created_time,permalink_url&limit=25
# or plain organic posts:
GET /v25.0/<PAGE_ID>/posts?fields=id,message,created_time,permalink_urlA6. Audiences & Targeting
Doc: /reference/custom-audience/, /audiences/.
Custom Audience: POST /act_{id}/customaudiences — name + subtype (CUSTOM general / LOOKALIKE / etc.) + customer_file_source (for customer lists). Upload users: POST /{ca_id}/users with schema + data (SHA-256 identifiers, normalize first: email trim+lowercase, phone digits-only with country code, first/last name lowercase a-z). Max 10k records/request. Delete users via the same edge with DELETE. (⚠️ old subtypes WEBSITE/ENGAGEMENT have likely merged into CUSTOM + rule — verify.)
Lookalike: POST /act_{id}/customaudiences — subtype=LOOKALIKE, origin_audience_id (seed ≥100 members, ideally 1k–5k), lookalike_spec={"type":"similarity"|"reach","country":"ID","ratio":0.01} (ratio 0.01–0.20). Populates in 1–6 hours; check delivery_status.code=200.
Targeting spec (on the ad set): geo_locations (countries/regions/cities/zips), age_min/age_max, genders ([1]=male, [2]=female), interests (search for ids), behaviors, custom_audiences/excluded_custom_audiences, flexible_spec (OR-of-AND), exclusions, publisher_platforms/facebook_positions/instagram_positions, device_platforms. Advantage+ audience: targeting_automation.advantage_audience: 1|0 (from v23.0 onward this must be set explicitly in many cases).
# Find an interest id
GET /v25.0/search?type=adinterest&q=Coffee&locale=en_US&limit=10
# Estimate reach
GET /act_<AD_ACCOUNT_ID>/delivery_estimate?optimization_goal=...&targeting_spec={...}A7. Insights & Reporting
Doc: /insights/. The /insights edge exists on account/campaign/adset/ad — same parameters throughout.
Key metrics: impressions, reach, frequency, spend, clicks, ctr, cpc, cpm, actions (array {action_type,value} — all conversions/engagement), action_values (currency value per action), cost_per_action_type (this is how you get CPA/CPL per action_type), purchase_roas (array — this is ROAS). ⚠️ results/cost_per_result are not API fields — they're a UI derivation (read optimization_goal → map to action_type).
Params: fields, level (account/campaign/adset/ad), date_preset (last_7d/last_30d/…) or time_range={"since","until"}, time_increment, breakdowns (age/gender/publisher_platform/platform_position/country/…), action_attribution_windows / use_unified_attribution_setting.
Derive: ROAS = purchase_roas[omni_purchase].value. CPA = cost_per_action_type[<action_type>].value. CTR = request ctr directly.
action_type key differs per surface (omni_purchase vs offsite_conversion.fb_pixel_purchase vs lead) — inspect the real actions array, don't hardcode blindly.GET /v25.0/<CAMPAIGN_ID>/insights?fields=spend,purchase_roas,actions,cost_per_action_type&date_preset=last_7d&level=campaign
Large reports → async: POST /{id}/insights → report_run_id → poll GET /{report_run_id} until async_status=Job Completed → GET /{report_run_id}/insights. (report_run_id expires after 30 days.)
Part B — CPAS / Collaborative Ads
How this differs from regular ops, and what it adds on top.
B0. What's different
CPAS (Collaborative Ads) = a brand advertises using a catalog owned by a retailer/marketplace (e.g. a third-party e-commerce platform). The retailer shares part of its catalog (a catalog segment) to the brand; the brand runs a catalog-sales campaign against that segment; conversion signal comes from the retailer's pixel, joined via retailer_id. This is how a brand advertises products whose storefront it doesn't own.
Built on top of Advantage+ catalog ads (B2). Main differences from regular ops: (1) the catalog belongs to another party (a segment), (2) attribution comes from the retailer's pixel, (3) many onboarding steps are UI-gated, (4) the brand has restrictions on editing creative/segment.
B1. Roles & onboarding
- Retailer/Merchant — owns the parent catalog + pixel; creates & shares catalog segments.
- Brand (advertiser) — accepts ToS, creates a dedicated ad account per retailer, runs campaigns.
- Marketing Partner/Agency — bridges brand↔retailer discovery.
B2. Catalog + Advantage+ catalog ads (foundation for both CPAS and non-CPAS)
- Catalog:
POST /{business_id}/owned_product_catalogs—name,vertical(commercedefault). ProductItem key =retailer_id(SKU, join key). - Product Set:
POST /{catalog_id}/product_sets—name+ afilterJSON (operatorsi_contains/contains/eq/is_any+and/or). ⚠️ VERIFY the exactfilterschema. - Feed:
POST /{catalog_id}/product_feeds(scheduled URL) or Catalog Batch API/items_batch. - Advantage+ catalog campaign:
objective=PRODUCT_CATALOG_SALES(or ODAXOUTCOME_SALES) +promoted_object={"product_catalog_id":"<CATALOG_ID>"}. - Ad Set:
promoted_object={"product_set_id":"<SET_ID>","custom_event_type":"PURCHASE"}. Retargeting → addproduct_audience_specs(inclusions/exclusions by event). Prospecting/DABA → omit it. - Dynamic creative:
object_story_spec.template_data+ tokens like{{product.name}}/{{product.price}}+product_set_id.
B3. CPAS flow (retailer → brand)
# RETAILER: create a catalog segment from the compliant parent catalog
POST /v25.0/{RETAILER_BUSINESS_ID}/owned_product_catalogs
-F 'name=BrandX Segment' -F 'parent_catalog_id=<RETAILER_PARENT_CATALOG_ID>'
-F 'catalog_segment_filter={"brand":{"i_contains":"BrandX"}}'
# → segment id (is_catalog_segment=true)
# RETAILER: share the segment to the brand's Business Manager
POST /v25.0/{CATALOG_SEGMENT_ID}/agencies
-F 'business=<BRAND_BUSINESS_ID>' -F 'permitted_tasks=["ADVERTISE"]'
-F 'enabled_collab_terms=["ENFORCE_CREATE_NEW_AD_ACCOUNT","ENFORCE_SHARE_AD_PERFORMANCE_ACCESS"]'
# BRAND: campaign uses the SEGMENT id as product_catalog_id (in the retailer-dedicated ad account)
POST /v25.0/act_<BRAND_AD_ACCOUNT_ID>/campaigns
-F 'objective=PRODUCT_CATALOG_SALES' -F 'promoted_object={"product_catalog_id":"<CATALOG_SEGMENT_ID>"}'
# BRAND: ad set uses a product_set FROM the segment + custom_event_type; creative+ad same as B2.Discovery (partner/brand): GET /{business_id}?fields=collaborative_ads_suggested_partners, GET /collaborative_ads_directory?fields=collaborative_ads_merchants, POST /{business_id}/collaborative_ads_collaboration_requests.
VERIFY: legacy CPAS edge names
cpas_business_setup/cpas_advertiser_partnership/cpas_collaboration_request are not confirmed live — what's confirmed is the collaborative_ads_* edges + /agencies shown above. Don't code against cpas_* without checking the live reference.B4. Brand restrictions (CPAS)
A brand cannot: edit the segment; change multi_share_end_card/description in template_data; use template_url_spec pointing to a non-merchant website. The segment & pixel are controlled by the retailer.
B5. CPAS Insights
CPAS-specific fields: catalog_segment_value, catalog_segment_value_omni_purchase_roas, catalog_segment_actions, converted_product_value, converted_product_quantity. Per-product breakdown: action_converted_product_id (⚠️ not supported at ad-account level — use campaign/adset/ad level).
Part C — Core (auth, batch, rate limits, errors, versioning)
C1. Token & scope
- System User token (no expiry) = recommended for production. Created in Business Manager → System Users (UI, not API).
- Long-lived user token (~60 days):
GET /v25.0/oauth/access_token?grant_type=fb_exchange_token&client_id=&client_secret=&fb_exchange_token=<short>(server-side, requires app secret). - Scopes:
ads_management,ads_read,business_management,catalog_management, +pages_*for Page assets.ads_management/business_managementneed App Review/Advanced Access for accounts you don't own.
C2. Batch & pagination
- Batch:
POST /v25.0/withbatch=[{method,relative_url,body,name,depends_on}]. Max 50 sub-requests. Each sub-request still counts against rate limits individually. Can chain with{result=name:$.id}+depends_on. - Pagination: cursor-based — follow
paging.nextuntil absent. Don't persist theaftercursor across sessions. - Ad review is async: after creating an ad, status is
PENDING_REVIEW→ polleffective_status.
C3. Rate limits (BUC — Business Use Case)
Header X-Business-Use-Case-Usage (Marketing API using a system/page token): {biz_id:[{type,call_count,total_cputime,total_time,estimated_time_to_regain_access}]}. All values are hourly quota percentages.
type:ads_insights,ads_management,custom_audience, etc. Standard-access formula (example): Ads Management300 + 40×active_ads/hour; Insights600 + 400×active_ads/hour.- Strategy: back off BEFORE hitting 100%. If throttled, stop — continuing to hit the API only extends the penalty. Transient rate-limit codes:
4,17,32,613,80000-80004→ retry with exponential backoff. Auth/permission codes (190,10,200,272) → do not retry, fix the token/scope instead.
C4. Error object
{error:{message, type, code, error_subcode, is_transient, error_user_msg, error_data.blame_field_specs, fbtrace_id}}. Branch on code/error_subcode, never parse message. fbtrace_id is for reporting to Meta. code 190 = invalid token, 100 = invalid param, 80004 = Ads Management BUC limit.
C5. Versioning
Each version lives ≥2 years after the next version ships. Deprecation of a specific field can be as short as 90 days (e.g. Advantage+ audience restrictions). Pin v25.0; watch the changelog.
Part D — Cookbook (ready-to-use test cases)
All examples use v25.0, an ads_management token, and <AD_ACCOUNT_ID> without the act_ prefix. USD examples are in cents. Always read → compute → write → readback.
D1. Scale up budget +30% (CBO campaign)
GET /v25.0/<CAMPAIGN_ID>?fields=daily_budget # "10000" POST /v25.0/<CAMPAIGN_ID> daily_budget=13000 GET /v25.0/<CAMPAIGN_ID>?fields=daily_budget # confirm 13000
D2. Scale down budget -30% (ABO adset)
Same pattern, POST to <ADSET_ID>, 5000 → 3500.
D3. Pause / Resume
POST /<ID> status=PAUSED / status=ACTIVE. Check effective_status (watch for CAMPAIGN_PAUSED).
D4. Get a post ID from an ad
GET /<AD_ID>?fields=creative{effective_object_story_id} # → split on "_"D5. Build an ad from an existing post
POST /act_<ID>/adcreatives object_story_id=<PAGE>_<POST>
POST /act_<ID>/ads adset_id=..&creative={"creative_id":".."}&status=PAUSEDD6. ROAS/CPA, last 7 days
GET /<CAMPAIGN_ID>/insights?fields=spend,purchase_roas,cost_per_action_type&date_preset=last_7d
D7. 1% Lookalike, Indonesia
POST /act_<ID>/customaudiences subtype=LOOKALIKE origin_audience_id=<SEED>
lookalike_spec={"type":"similarity","country":"ID","ratio":0.01}D8. CPAS setup
See B3 — retailer creates+shares a segment, then brand campaigns against the segment id.
D9. CPAS insights
GET /<CAMPAIGN_ID>/insights?fields=catalog_segment_value,catalog_segment_value_omni_purchase_roas,converted_product_quantity
Appendix — ⚠️ VERIFY points before production
- Minimum budget per billing_event/currency — computed dynamically by Meta, no fixed table. Read
error_user_msg, don't hardcode. - The "20% learning reset" rule — a heuristic, not an API contract.
attachment_hash— not in the v25.0 reference (useimage_hash). Check older Kanzen Ads code for it.- Old IG fields (
instagram_actor_id/instagram_story_id) — deprecated Sept 2025, useinstagram_user_id/effective_instagram_media_id. - Old CPAS edges (
cpas_*) — use the confirmedcollaborative_ads_*+/agencies. - Product-set
filterJSON and theoptimization_goal×billing_eventmatrix — verify live. action_typekeys (e.g.omni_purchase) — inspect the realactionsarray per account.
mcp/meta-marketing-api-guide.md) in sync when Meta ships a new API version.