APIs · Meta publishingMeta publishing.
Connect a workspace to Meta, then push ads into any ad account it can manage: upload the image, create the campaign, ad set, creative and ad, and read back performance. Everything is created paused. Going live stays a human click in Ads Manager, so a bad script can never spend money.
Before you start
- Your plan needs Meta Ads management (Earlybird Tier 3 and up). Other plans get
402. - Each workspace has its own Meta connection. Connect it over the API once; calls then run as that connection, across every ad account it can manage. Agencies running several clients use one workspace per client and pick it with
X-Workspace-Id. - Or send your own system-user token on each request with
X-Meta-Access-Token.
Authorization: Bearer bm_live_your_key_here
# optional: act on another workspace you belong to
X-Workspace-Id: 7e21b0c3-5d4a-4f9b-8a1e-2c3d4e5f6a7b
# optional: overrides the workspace's Meta connection for this request
X-Meta-Access-Token: EAAB...
Responses are the Meta Graph API response, passed through unchanged. Budgets are in the account currency's minor units (cents for USD), same as Graph.
The flow
POST /meta/connection/link (or /token) to connect the workspace, once.GET /meta/ad-accounts and GET /meta/ad-accounts/{id}/pages to pick the account and the page the ad runs as.POST /meta/images: image URL in, hash out.POST /meta/campaigns, then POST /meta/adsets.POST /meta/creatives with copy from /generate/ad-copy plus the image hash.POST /meta/ads ties the creative to the ad set.
Connect Meta
All connection endpoints act on the workspace the request resolves to. Reading the status needs the viewer role; everything else needs admin or owner.
Option 1: browser link
POST/api/v1/meta/connection/link
Returns a link valid for 30 minutes. Open it in a browser (or send it to whoever owns the client's Facebook login). It shows which workspace is being connected, hands off to Facebook, and ends on a “Meta connected” page. No dashboard involved. Poll GET /meta/connection to see when it lands.
curl -X POST https://api.brandmov.com/api/v1/meta/connection/link \
-H "Authorization: Bearer bm_live_your_key_here"
{
"connect_url": "https://api.brandmov.com/api/v1/auth/meta/connect?t=...",
"workspace_id": "1c9d2f4a-7b6e-4a2f-9c3d-8e5f1a2b3c4d",
"expires_at": "2026-10-02T10:44:03+00:00"
}
Option 2: system-user token
POST/api/v1/meta/connection/token
access_token | string, required | A Meta system-user or user token with ads_read, and ads_management for writes. |
We check the token with Meta before saving it. A token Meta rejects returns 400 meta_token_rejected and nothing is stored.
curl -X POST https://api.brandmov.com/api/v1/meta/connection/token \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"access_token": "EAAB...system-user-token"}'
Status
GET/api/v1/meta/connection
writes_unlocked is false during the short cool-down after a new connection; writes_unlock_at says when it ends. Reads work right away.
{
"workspace_id": "1c9d2f4a-7b6e-4a2f-9c3d-8e5f1a2b3c4d",
"connected": true,
"meta_user_id": "10223344556677",
"scopes": ["ads_management", "ads_read", "business_management"],
"expires_at": "2026-12-01T09:14:03+00:00",
"expired": false,
"selected_ad_account_id": "1234567890",
"writes_unlocked": true,
"writes_unlock_at": null
}
Pin a default ad account
PUT/api/v1/meta/connection/ad-account
account_id | string or null | act_123 or 123. Must be an account the connection can manage, else 404 ad_account_not_found. null clears it. |
curl -X PUT https://api.brandmov.com/api/v1/meta/connection/ad-account \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"account_id": "act_1234567890"}'
Disconnect
DELETE/api/v1/meta/connection
Removes this workspace's connection only. Other workspaces keep theirs. 404 meta_not_connected when there is nothing to remove.
List ad accounts
GET/api/v1/meta/ad-accounts
limit | int, query | 1 to 500. Default 200. |
curl https://api.brandmov.com/api/v1/meta/ad-accounts \
-H "Authorization: Bearer bm_live_your_key_here"
{
"data": [
{
"id": "act_1234567890",
"name": "Client A",
"account_status": 1,
"currency": "USD",
"timezone_name": "America/New_York"
}
]
}
List pages for an account
GET/api/v1/meta/ad-accounts/{account_id}/pages
Facebook pages that can be the identity on this account's ads. Use the id as page_id on the creative.
List campaigns
GET/api/v1/meta/ad-accounts/{account_id}/campaigns
limit | int, query | 1 to 200. Default 25. |
status_filter | string, query | e.g. ACTIVE, PAUSED. |
after | string, query | Paging cursor from the previous response. |
List ad sets
GET/api/v1/meta/ad-accounts/{account_id}/adsets
campaign_id | string, query | Only this campaign's ad sets. |
limit | int, query | 1 to 200. Default 25. |
after | string, query | Paging cursor from the previous response. |
List ads
GET/api/v1/meta/ad-accounts/{account_id}/ads
campaign_id / adset_id | string, query | Narrow to one campaign or ad set. |
limit | int, query | 1 to 200. Default 25. |
after | string, query | Paging cursor from the previous response. |
Insights
GET/api/v1/meta/insights/{object_id}
Spend, impressions, clicks, conversions and the rest of Meta's metrics for an ad account (act_...), campaign, ad set or ad.
time_range | string, query | Preset: last_7d, last_30d (default), lifetime and so on. |
since, until | YYYY-MM-DD, query | Custom range. Send both or neither, else 422. Overrides time_range. |
level | string, query | account, campaign, adset or ad (default). |
breakdown | string, query | e.g. age, gender, country, publisher_platform. |
time_increment | string, query | 1 for daily rows, 7 for weekly, all_days. |
limit | int, query | 1 to 500. Default 25. |
after | string, query | Paging cursor from the previous response. |
curl "https://api.brandmov.com/api/v1/meta/insights/act_1234567890?level=campaign&since=2026-09-01&until=2026-09-30&time_increment=1" \
-H "Authorization: Bearer bm_live_your_key_here"
Upload an image
POST/api/v1/meta/images
account_id | string, required | act_ prefixed. |
image_url | string, required | Public URL. We fetch it and upload it to the account's image library. |
name | string | Label in the library. |
curl -X POST https://api.brandmov.com/api/v1/meta/images \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"account_id": "act_1234567890", "image_url": "https://cdn.example.com/ad-1.jpg"}'
{
"images": {
"bytes": {
"hash": "4f1b2c...e9",
"url": "https://scontent.xx.fbcdn.net/..."
}
}
}
Create a campaign
POST/api/v1/meta/campaigns
account_id | string, required | |
name | string, required | |
objective | string, required | OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC, OUTCOME_ENGAGEMENT, OUTCOME_AWARENESS or OUTCOME_APP_PROMOTION. |
special_ad_categories | string[] | Required by Meta for housing, employment, credit and similar. Send [] otherwise. |
daily_budget / lifetime_budget | int | Set here for campaign budget optimisation; leave out to budget per ad set. |
bid_strategy, bid_cap, spend_cap, buying_type | | Passed to Graph as-is. |
curl -X POST https://api.brandmov.com/api/v1/meta/campaigns \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"account_id": "act_1234567890",
"name": "Sleep gummies | Prospecting",
"objective": "OUTCOME_SALES",
"special_ad_categories": []
}'
{ "id": "120200000000000003" }
Create an ad set
POST/api/v1/meta/adsets
account_id, campaign_id, name | string, required | |
optimization_goal | string, required | OFFSITE_CONVERSIONS, LINK_CLICKS, LEAD_GENERATION, REACH and so on. |
billing_event | string, required | Usually IMPRESSIONS. |
daily_budget / lifetime_budget | int | Leave out when the campaign carries the budget. |
targeting | object | Graph targeting spec. Defaults to US, 18 to 65, Advantage+ audience. |
promoted_object | object | e.g. {pixel_id, custom_event_type} for conversion goals. |
start_time, end_time, bid_strategy, bid_amount, attribution_spec, ... | | Passed to Graph as-is. |
We check the common mistakes before calling Meta: an ad set budget on a campaign that already has one, a bid strategy without its bid amount, and app installs without a promoted object.
curl -X POST https://api.brandmov.com/api/v1/meta/adsets \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"account_id": "act_1234567890",
"campaign_id": "120200000000000001",
"name": "US women 30-50",
"optimization_goal": "OFFSITE_CONVERSIONS",
"billing_event": "IMPRESSIONS",
"daily_budget": 3000,
"promoted_object": {"pixel_id": "111222333", "custom_event_type": "PURCHASE"},
"targeting": {
"geo_locations": {"countries": ["US"]},
"age_min": 30, "age_max": 50, "genders": [2]
}
}'
Create a creative
POST/api/v1/meta/creatives
account_id, name | string, required | |
page_id | string, required | The Facebook page the ad runs as. |
link_url | string | Destination URL. |
message | string | Primary text. Use a variant's primary_text. |
headline, description | string | |
call_to_action_type | string | Default LEARN_MORE. |
image_hash / image_hashes / video_id | | Media from the image upload (or an existing video). |
instagram_user_id | string | Run on Instagram as this account. |
asset_feed_spec, degrees_of_freedom_spec, url_tags | | Passed to Graph as-is. |
curl -X POST https://api.brandmov.com/api/v1/meta/creatives \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"account_id": "act_1234567890",
"name": "v1 | melatonin-free",
"page_id": "104000000000000",
"link_url": "https://example.com/sleep",
"message": "Wake up without the melatonin hangover. ...",
"headline": "Wake rested, every morning",
"description": "Clear mornings ahead",
"call_to_action_type": "GET_OFFER",
"image_hash": "4f1b2c...e9"
}'
Create an ad
POST/api/v1/meta/ads
account_id, name, adset_id, creative_id | string, required | |
tracking_specs, conversion_domain | | Optional, passed to Graph. |
curl -X POST https://api.brandmov.com/api/v1/meta/ads \
-H "Authorization: Bearer bm_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"account_id": "act_1234567890",
"name": "v1 | melatonin-free",
"adset_id": "120200000000000002",
"creative_id": "120200000000000003"
}'
Errors and limits
400: Meta rejected the request. The body carries Meta's error unchanged.402: your plan doesn't include Meta Ads management.409 meta_not_connected: this workspace has no Meta connection, or the token expired. Reconnect with POST /meta/connection/link.429: hourly or daily write limit hit. Retry later.403: writes are not open on this account yet. New Meta connections have a short cool-down before the first write. The body carries the exact spec and manual steps so nothing is lost:
{
"detail": {
"performed": false,
"reason": "writes_disabled",
"action": "create_ad",
"intended_params": { "...": "..." },
"manual_steps": ["Open Ads Manager ...", "..."]
}
}
A status field in any request body is ignored. Ads are always created paused.