Skip to content

Marketing (Meta ads)

Neike talks to Meta’s Marketing API: the part of Meta that creates and manages ads on Facebook and Instagram. Every endpoint lives under /api/marketing/*, authenticates with the same API key as the rest of the product, and works on ad accounts your users connect through Facebook Login.

Neike does not replace Ads Manager, and it is not a nicer wrapper around Meta’s own Marketing API. If you run one ad account for one business, keep using Meta’s tools.

Neike is for products that operate ads for many tenants and already run their messaging through Neike. Two things come out of that. First, one integration serves every customer: their ad accounts sit next to their WhatsApp, Instagram and Messenger channels, under the same client, key and webhook. Second — and this is the real reason the section exists — the ad and the conversation it started land in the same place. When someone taps a Click-to-WhatsApp ad and writes, the inbound message carries a referral with the ad’s id and click id. You get the ad, the click and the reply in one webhook, without stitching two systems together. See Attribution.

If you have never touched Meta ads, four boxes are enough:

ObjectWhat it isWhere it is decided
Ad account (act_123456)The wallet. Currency, timezone and a payment method loaded at Meta. All money leaves from here.Meta Ads Manager
CampaignThe objective: “traffic to my site”, “WhatsApp conversations”.Neike
Ad setWho, where, when, and how much per day.Neike
AdWhat people see. Points at a creative — the text, the image, the button.Neike

An ad needs all four: no account, no money; no campaign, no objective; no ad set, no audience; no creative, nothing to show.

Permissions. The Facebook Login asks for four Meta permissions in one go:

PermissionUsed forOptional?
ads_managementCreate, edit and pause campaigns, ad sets, ads and creatives.No. Without it the OAuth exchange fails with 400 and error: "missing_permission".
ads_readRead insights.Yes, but you get no numbers.
business_managementResolve which business owns the account.Yes; the account has no business_name.
pages_show_listList Facebook pages — where a creative’s page_id comes from.Yes, but you cannot create creatives.

App Review. Operating ad accounts that belong to other people requires Meta’s Advanced Access for ads_management and ads_read, which in turn requires App Review and Business Verification. Until that is granted, the permissions only work for accounts of people with a role on the app: a customer going through the OAuth flow sees the permission screen and gets an empty list of ad accounts back.

Payment. The payment method lives in Meta Ads Manager, on the ad account. It is not your Neike wallet: the wallet pays for messages, Meta bills the ads to the advertiser directly. If the account has no payment method at Meta, Neike creates the objects without error and nothing is ever delivered.

Marketing is the one part of Neike that spends the advertiser’s money. Two locks guard it, and both have to be opened on purpose.

marketing:manage is not in the default preset of an API key. A key created without an explicit scope list gets every operational scope except two: keys:manage and marketing:manage. To let a key create ads, name it when you create the key in the console:

{ "label": "backend production", "scopes": ["messages:send", "marketing:manage"] }

marketing:read is inherited: looking at campaigns and metrics spends nothing. In console sessions the scope comes from the role — owner and admin can manage, member and readonly can only read.

Even with the scope, nothing that can increase spend runs until someone on your team accepts Neike’s terms of responsibility for ad spend. The text is short: you decide and pay for the advertising, Meta bills you directly, Neike does what you ask and tells you what happens — and you can pause or disconnect at any time.

MethodPathScopeWhat it does
GET/api/marketing/termsmarketing:readCurrent version and whether this client accepted it.
POST/api/marketing/terms/accept{ "version": "2026-08-30" }marketing:manageRecords the acceptance.

Both return the same shape (POST answers 200 with the updated state):

{
"version": "2026-08-30",
"accepted": true,
"accepted_at": "2026-08-30T14:32:11.000Z",
"accepted_by": { "user_id": 42, "name": "Dennis", "email": "[email protected]" },
"current_version_accepted": true
}

accepted and current_version_accepted say the same thing — the version in force is accepted. accepted_at and accepted_by describe the latest acceptance, whatever its version: accepted: false next to a populated accepted_at means “an older version was accepted, someone has to accept the new one”. Publishing a new version invalidates every previous acceptance.

Without a current acceptance, every call that can increase spend answers 403 with a stable error you can branch on:

{
"error": "marketing_terms_not_accepted",
"message": "Antes de operar anuncios, alguien con rol dueño o administrador tiene que aceptar la responsabilidad por el gasto publicitario.",
"terms_version": "2026-08-30"
}

That covers POST, PATCH and DELETE on campaigns, ad sets, ads, creatives, images and videos; POST /insights/reports; and ad accounts, including the OAuth exchange and the PATCH that activates an account.

Reads are never blocked. A GET costs nothing, and locking a customer out of the metrics of campaigns already running would blind them exactly when they need to see what is happening to their money.

Anything that can only lower spend also passes, acceptance or not:

MethodPathBodyWhy
DELETE/ad-accounts/{id}Disconnecting deletes the token, the only thing Neike can spend with.
PATCH/ad-accounts/{id}exactly { "status": "disabled" }Turns the account off. "active" does not pass: that enables spending.
PATCH/campaigns/{id}, /adsets/{id}, /ads/{id}status set to PAUSED or ARCHIVED, and nothing elsePausing and archiving stop spend; activating starts it.

The reason is the day a new version of the terms is published: every client with running campaigns falls back to 403 at once. If pausing were behind the gate, they would watch their ads keep spending until an owner logged in. The gate exists so nobody spends by accident, not to stop anyone from stopping.

A PATCH passes only if the status is the only change (ad_account_id is always required and does not count). { "status": "PAUSED", "daily_budget": 500000 } is blocked like any other edit; so is status: "DELETED" and the DELETE of a campaign — those need acceptance.

There is no endpoint that takes a token. The only way in is Facebook Login, so the credential that spends an advertiser’s budget is never pasted into a request body. The console does this under Marketing → Connect account; the steps below are what happens underneath.

  1. Terminal window
    curl "https://api.neike.dev/api/marketing/oauth/url?redirect_uri=https://app.neike.dev/marketing/callback" \
    -H "x-neike-api-key: whk_live_…"
    {
    "url": "https://www.facebook.com/v25.0/dialog/oauth?client_id=…&state=…",
    "state": "eyJ…"
    }

    The state is signed, expires in 10 minutes and is bound to the client that asked for it. Send the person to url.

  2. Meta redirects back with a code. Send it, the same redirect_uri and the state:

    Terminal window
    curl -X POST https://api.neike.dev/api/marketing/oauth/exchange \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "code": "AQD…",
    "redirect_uri": "https://app.neike.dev/marketing/callback",
    "state": "eyJ…"
    }'

    Neike exchanges the code, extends the token to a long-lived one right away (a one-hour token turns every connected account into a broken one the next day), reads /me/adaccounts and registers one row per ad account the person can see — all in status available:

    {
    "data": [
    {
    "id": "7c1b2e9a-4f3d-4a8e-9b6c-2d5e8f1a3b4c",
    "ad_account_id": "act_123456",
    "account_id": "123456",
    "name": "Aurora Store",
    "currency": "ARS",
    "timezone_name": "America/Argentina/Buenos_Aires",
    "account_status": 1,
    "account_status_label": "active",
    "business_id": "987654321",
    "business_name": "Aurora S.A.",
    "status": "available",
    "last_synced_at": "2026-08-30T14:40:02.000Z",
    "created_at": "2026-08-30T14:40:02.000Z"
    }
    ],
    "paging": { "after": null }
    }

    The token is stored encrypted (AES-256-GCM) and never leaves the server: not in a response, not in a log, not in a webhook.

  3. Connecting is not enabling. Meta letting a person see eight accounts does not mean you want Neike operating all eight. {id} here is Neike’s id (the uuid), not the act_ id:

    Terminal window
    curl -X PATCH https://api.neike.dev/api/marketing/ad-accounts/7c1b2e9a-4f3d-4a8e-9b6c-2d5e8f1a3b4c \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{ "status": "active" }'

    This is the call that emits ad_account.connected — once per account. disabled turns it back off without disconnecting; DELETE /api/marketing/ad-accounts/{id} disconnects and deletes the token.

Two helpers you will need later: GET /api/marketing/pages?ad_account_id=act_123456 lists the Facebook pages the connected user can publish from (the page_id of a creative), and GET /api/marketing/enums returns the catalogue of valid objectives, optimisation goals, calls to action and so on — read it instead of hard-coding values that Meta adds to every quarter.

Every request says which account it works on: ad_account_id as a query parameter on GET and DELETE, as a body field on POST and PATCH. The value is Meta’s act_<id>. An account that is not yours answers 404, exactly like one that does not exist; an object that hangs from another account also answers 404. Every Meta id must be numeric (with the act_ prefix for accounts) — anything else is 400 before it reaches Meta.

Three things that bite if you do not know them:

  • Amounts are in cents of the account’s currency. daily_budget: 100000 on an ARS account is $1,000. daily_budget and lifetime_budget are mutually exclusive (400 if both come), and lifetime_budget requires end_time.
  • special_ad_categories is mandatory at Meta. If the campaign falls in none, send [] — Neike does that for you when the field is missing.
  • Everything is born PAUSED. Campaigns, ad sets and ads default to PAUSED unless you say otherwise, so nothing spends without being asked. To go live you activate the campaign, the ad set and the ad.
  1. Terminal window
    curl -X POST https://api.neike.dev/api/marketing/campaigns \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "ad_account_id": "act_123456",
    "name": "Traffic · September",
    "objective": "OUTCOME_TRAFFIC",
    "special_ad_categories": []
    }'

    The response is the campaign as Meta stores it, with a numeric id such as "120210000000000000". OUTCOME_* objectives are the ones Meta recommends today; the full list is in /enums.

  2. Who sees it, where, from when and with how much per day. targeting travels as an object — countries, age range, platforms and placements. Saved audiences are not supported.

    Terminal window
    curl -X POST https://api.neike.dev/api/marketing/adsets \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "ad_account_id": "act_123456",
    "campaign_id": "120210000000000000",
    "name": "AR 25-45 · feed",
    "daily_budget": 50000,
    "billing_event": "IMPRESSIONS",
    "optimization_goal": "LINK_CLICKS",
    "start_time": "2026-09-01T00:00:00-0300",
    "targeting": {
    "geo_locations": { "countries": ["AR"] },
    "age_min": 25,
    "age_max": 45,
    "publisher_platforms": ["facebook", "instagram"]
    }
    }'

    Which objective works with which optimisation goal and billing event is validated by Meta, not by Neike: an incompatible combination comes back as a 400 with Meta’s message (see Errors).

  3. Upload the picture once and reference it by hash. The body is base64 without the data: prefix; the file itself can be up to 7 MB.

    Terminal window
    curl -X POST https://api.neike.dev/api/marketing/images \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "ad_account_id": "act_123456",
    "name": "spring-banner.jpg",
    "bytes_base64": "/9j/4AAQSkZJRg…"
    }'
    { "hash": "a1b2c3d4e5f6…", "url": "https://scontent…", "width": 1200, "height": 628, "name": "spring-banner.jpg" }

    Video goes through POST /api/marketing/videos with a public file_url that Meta downloads; the response id goes in video_data.video_id.

  4. The text, the image, the button, and the Facebook page that publishes it (page_id from GET /pages). link_data and video_data are mutually exclusive.

    Terminal window
    curl -X POST https://api.neike.dev/api/marketing/creatives \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "ad_account_id": "act_123456",
    "name": "Traffic creative",
    "object_story_spec": {
    "page_id": "1234567890",
    "link_data": {
    "message": "New season, new prices.",
    "link": "https://mystore.com/promo",
    "name": "Spring sale",
    "image_hash": "a1b2c3d4e5f6…",
    "call_to_action": { "type": "SHOP_NOW", "value": { "link": "https://mystore.com/promo" } }
    }
    }
    }'

    For a Click-to-WhatsApp ad the call to action is WHATSAPP_MESSAGE; for Messenger, MESSAGE_PAGE.

  5. Terminal window
    curl -X POST https://api.neike.dev/api/marketing/ads \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{
    "ad_account_id": "act_123456",
    "name": "Traffic ad · image",
    "adset_id": "120210000000000001",
    "creative": { "creative_id": "120210000000000002" }
    }'

    An ad cannot be moved to another ad set afterwards — adset_id on a PATCH is 400. Create a new one instead.

  6. Meta reviews the ad first; effective_status tells you where it is. When you are ready, activate the three levels. Each of these needs the terms accepted:

    Terminal window
    curl -X PATCH https://api.neike.dev/api/marketing/ads/120210000000000003 \
    -H "x-neike-api-key: whk_live_…" \
    -H "Content-Type: application/json" \
    -d '{ "ad_account_id": "act_123456", "status": "ACTIVE" }'

    Same call, same body, on /campaigns/{id} and /adsets/{id}. Pausing is the same PATCH with "PAUSED" — and that one always works.

Lists (GET /campaigns, /adsets, /ads, /creatives) always return { "data": [...], "paging": { "after": string | null } }. limit is a decimal integer from 1 to 100; 1e3, 1.5 or 12abc are 400, not silently clamped. Filter by status, and by campaign_id (ad sets and ads) or adset_id (ads). The fields of every request and response are in the API reference.

GET /api/marketing/insights answers synchronously and is what you want for a dashboard: the last 7 days of a campaign, today’s spend of the account.

Terminal window
curl "https://api.neike.dev/api/marketing/insights?ad_account_id=act_123456&object_id=120210000000000000&level=campaign&date_preset=last_7d&time_increment=1" \
-H "x-neike-api-key: whk_live_…"
ParameterNotes
object_idCampaign, ad set or ad. Without it, the whole account.
levelaccount, campaign, adset or ad.
date_presetA named range: today, yesterday, last_7d, last_28d, this_month, last_month, maximum… Free string; Meta validates it. /enums lists the usual ones.
time_rangeExplicit range, as JSON in the query string: {"since":"2026-08-01","until":"2026-08-31"}.
time_incrementRow granularity: a number of days (1, 7, 28), monthly or all_days. Without it Meta aggregates the whole range into one row.
fieldsComma-separated metrics. Default: impressions,clicks,spend,reach,frequency,cpc,cpm,ctr,actions,cost_per_action_type,date_start,date_stop.
breakdowns, action_breakdownsComma-separated, e.g. age,gender.

Meta only looks back 37 months. A range that starts earlier is rejected with Meta code 3018, which Neike passes through as a meta_error.

For big ranges — a year of daily rows broken down by age and gender — ask for an asynchronous report and poll it:

Terminal window
# 1. Request the report (needs the terms accepted)
curl -X POST https://api.neike.dev/api/marketing/insights/reports \
-H "x-neike-api-key: whk_live_…" \
-H "Content-Type: application/json" \
-d '{
"ad_account_id": "act_123456",
"level": "ad",
"time_range": { "since": "2026-01-01", "until": "2026-08-31" },
"time_increment": "1",
"breakdowns": "age,gender"
}'
# 2. Poll until async_status is "Job Completed"
curl "https://api.neike.dev/api/marketing/insights/reports/6200000000000000?ad_account_id=act_123456" \
-H "x-neike-api-key: whk_live_…"
# 3. Page through the rows
curl "https://api.neike.dev/api/marketing/insights/reports/6200000000000000/results?ad_account_id=act_123456&limit=100" \
-H "x-neike-api-key: whk_live_…"

The status call returns report_run_id, async_status (Job Not Started, Job Started, Job Running, Job Completed, Job Failed…) and async_percent_completion. Reports expire after 30 days at Meta: a report_run_id from last month returns nothing.

When something happens to your ads, Neike POSTs to your backend. Same mechanism as the messaging webhooks — your webhook_url plus a path, your shared token in x-webhook-token — with its own path:

{webhook_url}/ads/events

Without a webhook_url configured nothing is dispatched, but the event is still recorded as failed with error_code=no_webhook_url, so it can be replayed once you set one.

eventWhen
ad_account.connectedThe account became operable: it went active. Once per account.
ad_account.sync_completedFinished pulling the account’s data from Meta.
ad_account.disconnectedDisconnected, or Meta revoked the token.
campaign.created · campaign.updated · campaign.status_changed · campaign.deletedMutations made through the Neike API.
adset.created · adset.updated · adset.status_changed · adset.deletedSame.
ad.created · ad.updated · ad.status_changed · ad.deletedSame.
adcreative.created · adcreative.deletedSame.
ad.review_failedMeta rejected an ad in review. Comes from Meta’s webhook.
ad.issues_detectedMeta flagged objects with problems. Same.
ad.creative_fatigueThe creative is wearing out: the same people saw it too often. Same.

The OAuth exchange does not emit ad_account.connected: accounts come back available, which means Meta lets you see them, not that Neike operates them. What the exchange emits is one ad_account.sync_completed per account — that is how you learn which accounts appeared. connected fires once, on the PATCH that makes an account active.

*.created, *.updated, *.status_changed and *.deleted are emitted after Meta confirmed the operation: if the notification fails, the campaign still exists and the endpoint still answered 200. A PATCH that moves status emits status_changed with data.status = { from, to }; one that changes anything else emits updated with data.fields, the list of fields touched. A PATCH that changes the status and something else emits a single status_changed.

Always the same envelope:

{
"event": "campaign.status_changed",
"event_id": "9b0e6b6c-6d8f-4b3a-9a1c-0f5d2c7e4a11",
"occurred_at": "2026-08-29T12:00:00.000Z",
"ad_account_id": "act_123456",
"object": { "type": "campaign", "id": "120210000000000000", "name": "Traffic · September" },
"data": { "status": { "from": "PAUSED", "to": "ACTIVE" } }
}
FieldDetail
eventOne of the table above. Closed list.
event_iduuid of the event. Use it to deduplicate: a replay resends the same event_id.
occurred_atISO 8601, UTC.
ad_account_idAlways with the act_ prefix.
objectWhat changed. type is ad_account, campaign, adset, ad, adcreative or product_set. name can be null.
dataEvent-specific. Its shape depends on the event.

The ad account token never travels in the payload: before sending, Neike strips any key in data that looks like a credential (*token*, *secret*, *password*, *signature*), at any depth.

Every dispatch is recorded in the event store with direction: "marketing" and platform: "meta_ads", visible in the console under Event history. There is no automatic retry with backoff for these notifications: a failed one is replayed from the console, or with POST /api/events/{id}/retry. The replay repeats the same POST with the same envelope and re-resolves your current webhook_url.

Meta notifies on its own when something happens on its side — a rejected ad, a burnt-out creative. That webhook reaches Neike, not you; Neike translates it and forwards it with the envelope above.

The callback is https://api.neike.dev/webhook/meta-ads, verified with Meta’s hub.challenge handshake and signed with X-Hub-Signature-256 like the messaging webhook. The subscription lives on Neike’s Meta app (product Webhooks, object ad_account); there is nothing to configure on your side. Neike answers 200 immediately and processes afterwards, and a webhook for an account nobody connected is ignored with 200.

How each subscribed ad_account field becomes a Neike event:

ad_account fieldNeike event
with_issues_ad_objectsad.review_failed if it carries a review rejection; otherwise ad.issues_detected.
creative_fatiguead.creative_fatigue
in_process_ad_objectsad.issues_detected
product_set_issuead.issues_detected (with object.type: "product_set")
ads_async_creation_requestad.updated
ad_recommendationsad.updated

The last four have no event of their own in the contract. They are sent with the closest one and the original field name travels in data.meta_field; the rest of Meta’s value is forwarded as-is inside data. If the same Meta account is connected by two Neike clients (an agency and its customer), the webhook produces one event for each, with its own event_id.

This is the piece that joins the two halves of Neike. When a person opens a conversation from a Click-to-WhatsApp, Click-to-Messenger or Click-to-Instagram ad, Meta attaches a referral object to the first message. Neike forwards it verbatim as message.referral on the same message.received webhook you already handle:

{
"event": "message.received",
"channel": { "id": "nk_dvzobn7m4h4y", "platform": "whatsapp" },
"contact": { "id": "5491112345678", "name": "Ana", "phone": "+5491112345678", "username": null, "avatar_url": null },
"message": {
"id": "wamid.HBgNNTQ5MTEy…",
"direction": "inbound",
"timestamp": "2026-08-30T15:02:11.000Z",
"type": "text",
"content": { "text": "Hi, I saw the spring sale — is it still on?" },
"reply_to": null,
"referral": {
"source_url": "https://fb.me/2abc…",
"source_id": "120210000000000003",
"source_type": "ad",
"headline": "Spring sale",
"body": "New season, new prices.",
"media_type": "image",
"ctwa_clid": "ARAkLkA8rmlFeiCC…"
}
}
}

referral is additive: it only appears when the conversation came through an ad, and only on the first message. A handler that ignores it keeps working exactly as before.

The shape depends on the platform.

WhatsApp. Meta does not document this object officially — the field names come from the community and from partners — so Neike forwards it raw, with nothing filtered: a field Meta adds or renames tomorrow still reaches you. What has been seen:

FieldMeaning
source_idId of the ad (or post) that was clicked. Matches the id of an ad you created through /api/marketing/ads.
source_typead.
source_urlURL of the ad.
headline, bodyThe ad’s title and text.
media_typeimage or video.
ctwa_clidThe Click-to-WhatsApp click id. Can legitimately be missing — a click on the post rather than the ad, desktop traffic, a WABA not linked to the ad account.

Messenger and Instagram. This one Meta does document (messaging_referrals). Same forwarding rule — verbatim — and these fields:

FieldMeaning
refThe ref parameter of the ad or the m.me link.
sourceADS or SHORTLINK.
typeOPEN_THREAD.
ad_idId of the ad.
referer_uriURL the thread was opened from.
ads_context_dataad_title, photo_url, video_url, post_id, product_id, flow_id.

So a full loop looks like this: you create an ad with a WHATSAPP_MESSAGE call to action, a customer taps it and writes, the message.received webhook arrives with referral.source_id equal to that ad’s id, and every message after that belongs to a conversation you can attribute — and later measure against the ad’s spend from /insights.

Anything that goes wrong at Meta comes back with a stable body, whatever the endpoint. message is Meta’s advertiser-facing text (error_user_msg) when there is one, otherwise the technical one:

{
"error": "meta_error",
"message": "Your ad set budget is below the minimum allowed.",
"meta": {
"code": 1487872,
"subcode": null,
"type": "OAuthException",
"title": "Budget too low",
"fbtrace_id": "AbCdEf123"
}
}

The HTTP status follows Meta’s code:

Meta codeHTTPMeaningWhat to do
190, 102, 463401The account’s token expired, was revoked or belongs to another session.Reconnect the ad account through OAuth.
200, 10, 803403The user has no permission on that object, or a scope is missing.Check the account’s roles at Meta.
4, 17, 613, 80000, 80004429Throttling — app-level or ad-account-level.Back off and retry.
100, 14870001487999400Invalid parameter, or one of the Marketing API’s own validations (budget, targeting, incompatible objective).Fix the request.
anything else, or no code502Meta failed in a way Neike does not interpret, or did not answer at all.Retry with backoff.

Neike’s own checks come first and use the usual codes: 400 for a non-numeric id or an invalid limit, 403 with marketing_terms_not_accepted for the terms gate, 404 for an ad account or object that is not yours.

What Neike does not do today, so nobody finds out halfway through an integration:

  • Product catalogues (product_catalog, product_set): not created or managed. Only Meta’s product_set_issue notice is forwarded.
  • Audiences (Custom Audiences, Lookalike): not created or listed. An ad set’s targeting is built from geography, age, gender, platforms and placements, not from saved audiences.
  • Experiments / A-B tests (ad_studies).
  • Pixel and conversion events: Neike neither creates pixels nor sends events through the Conversions API. You can reference an existing pixel_id in an ad set’s promoted_object, nothing more.
  • Chunked video upload: POST /videos hands Meta a URL. Meta’s resumable upload protocol for large files is not implemented.
  • Automated rules, shared campaign budgets and bulk edits.
  • Meta billing: the payment method and the balance live in Meta Ads Manager. Neike’s wallet pays for messages, not for ads, and Neike never advances, intermediates or sees the ad spend.