Meta 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

  1. POST /meta/connection/link (or /token) to connect the workspace, once.
  2. GET /meta/ad-accounts and GET /meta/ad-accounts/{id}/pages to pick the account and the page the ad runs as.
  3. POST /meta/images: image URL in, hash out.
  4. POST /meta/campaigns, then POST /meta/adsets.
  5. POST /meta/creatives with copy from /generate/ad-copy plus the image hash.
  6. 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_tokenstring, requiredA 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_idstring or nullact_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
limitint, query1 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
limitint, query1 to 200. Default 25.
status_filterstring, querye.g. ACTIVE, PAUSED.
afterstring, queryPaging cursor from the previous response.

List ad sets

GET/api/v1/meta/ad-accounts/{account_id}/adsets
campaign_idstring, queryOnly this campaign's ad sets.
limitint, query1 to 200. Default 25.
afterstring, queryPaging cursor from the previous response.

List ads

GET/api/v1/meta/ad-accounts/{account_id}/ads
campaign_id / adset_idstring, queryNarrow to one campaign or ad set.
limitint, query1 to 200. Default 25.
afterstring, queryPaging 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_rangestring, queryPreset: last_7d, last_30d (default), lifetime and so on.
since, untilYYYY-MM-DD, queryCustom range. Send both or neither, else 422. Overrides time_range.
levelstring, queryaccount, campaign, adset or ad (default).
breakdownstring, querye.g. age, gender, country, publisher_platform.
time_incrementstring, query1 for daily rows, 7 for weekly, all_days.
limitint, query1 to 500. Default 25.
afterstring, queryPaging 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_idstring, requiredact_ prefixed.
image_urlstring, requiredPublic URL. We fetch it and upload it to the account's image library.
namestringLabel 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_idstring, required
namestring, required
objectivestring, requiredOUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC, OUTCOME_ENGAGEMENT, OUTCOME_AWARENESS or OUTCOME_APP_PROMOTION.
special_ad_categoriesstring[]Required by Meta for housing, employment, credit and similar. Send [] otherwise.
daily_budget / lifetime_budgetintSet here for campaign budget optimisation; leave out to budget per ad set.
bid_strategy, bid_cap, spend_cap, buying_typePassed 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, namestring, required
optimization_goalstring, requiredOFFSITE_CONVERSIONS, LINK_CLICKS, LEAD_GENERATION, REACH and so on.
billing_eventstring, requiredUsually IMPRESSIONS.
daily_budget / lifetime_budgetintLeave out when the campaign carries the budget.
targetingobjectGraph targeting spec. Defaults to US, 18 to 65, Advantage+ audience.
promoted_objectobjecte.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, namestring, required
page_idstring, requiredThe Facebook page the ad runs as.
link_urlstringDestination URL.
messagestringPrimary text. Use a variant's primary_text.
headline, descriptionstring
call_to_action_typestringDefault LEARN_MORE.
image_hash / image_hashes / video_idMedia from the image upload (or an existing video).
instagram_user_idstringRun on Instagram as this account.
asset_feed_spec, degrees_of_freedom_spec, url_tagsPassed 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_idstring, required
tracking_specs, conversion_domainOptional, 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.