Skip to content

Meta Ads (Marketing API)

Pulls campaign structure, ad creatives and daily performance from an organization's Meta ad accounts into ConnectGain, and traces Meta leads back to the ad that produced them.

Meta Ads reporting inside ConnectGain Meta Ads reporting inside ConnectGain Meta Ads reporting inside ConnectGain.

Surfaced at /ads, behind the meta_ads feature gate.


What it reads

Data Source edge Stored in
Ad accounts (act_<id>) /me/adaccounts meta_ad_accounts
Campaigns /act_<id>/campaigns meta_ad_campaigns
Ad sets + targeting /act_<id>/adsets meta_ad_sets
Ads + creative /act_<id>/ads meta_ads
Daily insights (ad / campaign / account) /act_<id>/insights meta_ad_insights

Headline metrics

  • Spend — reported in the org's own currency where a rate is available; see Currency below.
  • Click-through rate — recomputed as clicks / impressions over the whole window, never averaged from the per-day ctr column.
  • Cost per messagespend / messaging conversations started, the click-to-message metric. The conversation count is denormalised out of actions into meta_ad_insights.messaging_conversations, preferring onsite_conversion.messaging_conversation_started_7d (what Ads Manager shows as "Messaging conversations started") and falling back to total_messaging_connection then messaging_first_reply. Only the first present type is counted — summing them would multiply-count one conversation attributed several ways.

The integration is read-only. It never creates, edits, pauses or spends against an ad account.


Permissions & access level

The connection requests two scopes:

  • ads_read — campaign structure and insights.
  • business_management — lets /me/adaccounts see accounts owned by a Business Manager, not only personally-owned ones. Already requested by the Messenger / Instagram / WhatsApp connect flows, so it is not new to the app.

ads_read is the only permission this integration adds.

Two access levels, and which one you need

Meta grades each permission as Standard or Advanced access:

Level Whose ad accounts can be connected Review
Standard Only people with a role on the ConnectGain app (admin / developer / tester) None — auto-approved
Advanced Any user, including customers with no relationship to the app Business Verification + App Review

Own-use / internal phase → Standard Access is enough. Nothing to submit. Two prerequisites for whoever connects:

  1. A role on the ConnectGain Facebook app (App Dashboard → App roles).
  2. A role on the ad account itself in Business Settings — an app role alone does not grant access to someone else's ad account.

Customer-facing phase → Advanced Access on ads_read, which needs Business Verification (already in place for this app, since business_management, whatsapp_business_management and Embedded Signup all depend on it) plus App Review of the permission.

Someone outside the app's roles who connects before Advanced Access is granted comes back with a token that has no ads_read. meta-ads-connect rejects that with a 403 and an explanation rather than storing a token that cannot read anything.

Marketing API Access Tier is a different thing

Formerly "Ads Management Standard Access" (AMSA), renamed in 2026 precisely because it kept being confused with the ads_management permission. It governs rate limits, system-user quotas and Business Manager scope — not what data may be read — and runs Limited Access → Full Access.

Eligibility for Full Access is usage-gated: 500+ Marketing API calls in the past 15 days with an error rate under 15%. So it cannot be applied for cold. Ship on Limited, then upgrade if rate limits start to bite. One sync of one ad account is roughly 6–20 calls (campaigns + ad sets + ads + three insight levels, plus pagination), so a handful of accounts on a daily sync clears the bar in about two weeks.

Feature gating during the internal phase

meta_ads is registered in RESTRICTED_FEATURES, so /ads is visible only to internal orgs (@appgain.io / @ikhair.net), the demo org, or an org that explicitly lists meta_ads in enabled_features. No customer sees the page until it is switched on for them deliberately — which lines up with Standard Access covering only app-role holders.


Currency

Meta only ever reports in the ad account's currency. To quote spend in the org's currency (organizations.settings->>'currency', read via get_user_organization_info), meta-ads-sync resolves the rate that applied on each row's own day and stores it on the row:

  • meta_ad_insights.fx_rate — 1 ad-account-currency in org currency on date_start
  • meta_ad_insights.fx_org_currency — which currency that rate targets, so an org that later changes its reporting currency ignores the stale rates rather than silently misapplying them

Per-day rates are what make a long report honest: three months of spend converts day by day, not restated at today's rate.

Rate source

the platform fetches from the @fawazahmed0 currency API, served from public CDNs (cdn.jsdelivr.net, with currency-api.pages.dev as a second host). It is free, needs no key or secret, and quotes ~340 currencies — including the ones the free ECB-backed APIs omit, such as EGP. Both hosts are hardcoded, so there is no user-supplied URL and nothing for the SSRF guard to police.

Rates are cached in public.fx_rates, keyed (rate_date, base_currency, quote_currency). The cache is deliberately not org-scoped: rates are public reference data identical for every tenant, so org-scoping would refetch and restore the same numbers per org. The table is service-role only (RLS on, no policies) — clients never read it, because the rate that matters is denormalised onto the insight row.

Missing days (weekends, holidays, today before the snapshot publishes) carry the most recent rate forward, up to 7 days server-side. A remaining gap is filled client-side from the nearest neighbouring row, and the overview notes how many days borrowed a rate.

Fallback

meta_ad_accounts.fx_rate_to_org_currency is a manual per-account rate, edited from the Accounts tab and shown only when the two currencies differ. It is a fallback, consulted only for rows with no fetched rate — covering a currency the provider doesn't quote, or a stretch where lookups failed. It applies one rate across the window, so it is less accurate than the per-day rates.

Precedence

Per row: the row's own fx_rate → the nearest neighbouring row's rate → the account's manual fallback → no conversion. If nothing yields a rate, figures stay in the ad account's own currency and the page says so. That last step is deliberate: printing an unconverted number under the org's currency symbol would misstate spend by whatever the real rate is.

Conversion is applied to rows before anything is summed, so every derived figure — cost per lead, cost per message, CPC, CPM — comes out in one currency. Ratios (return on spend) are currency-independent; converting both sides cancels out.


Token handling

meta-ads-connect exchanges the OAuth code server-side. The resulting user token can read every ad account the person administers, so it never reaches the browser: it is exchanged for a ~60-day long-lived token and stored in meta_ad_account_secrets, a service-role-only table with RLS on and no policies (the same shape as channel_account_secrets).

token_expires_at is captured from debug_token; the Ads page warns in the account list once a token is within 7 days of expiring. Reconnecting refreshes the token in place and does not re-enable accounts the org had parked.

Discovery lists every ad account the Facebook user can read, but only the ones ticked in the connect dialog stay attached to the org. Unticked rows are disconnected on save (and newly discovered rows are dropped if the dialog is cancelled), so Accounts shows authorized accounts only — not the full FB portfolio.


Sync

meta-ads-sync accepts { ad_account_uuid?, since?, until? }.

  • Defaults to the last 30 days; a window longer than 92 days is clamped, and the response sets window_clamped so the UI can say so.
  • Insights are stored one row per (level, object, day) and upserted on that key, so re-running an overlapping window corrects those days rather than double-counting spend.
  • Campaign- and account-level rows are pulled from Meta rather than summed from ad-level rows: Ads Manager's campaign totals include spend from since-deleted ads, so summing would under-report.
  • One failing ad account does not abort the others — the error lands in meta_ad_accounts.last_sync_error and the run continues. A Graph code 190 is rewritten to a "reconnect this ad account" message.
  • verify_jwt = false + authenticateRequest, so a cron or service-role caller can drive it by passing organization_id in the body (same pattern as meta-leadgen-sync).

Scheduling

There is no cron job wired up yet — the Ads page syncs on demand via its Sync button. To run it nightly, schedule meta-ads-sync per organization the same way the other cron-invoked functions are scheduled.


Lead attribution

the platform now reads ad_id, adset_id, campaign_id, ad_name, campaign_name and platform off each Meta lead:

  • The webhook path picks them up from its per-lead Graph fetch, which now requests those fields explicitly.
  • The sync path lists them inline with field_data and passes them through as adAttribution.

They are written to meta_leadgen_events (indexed by ad_id and campaign_id) and onto the contact's custom_fields as meta_ad_id, meta_ad_name, meta_adset_id, meta_adset_name, meta_campaign_id, meta_campaign_name and meta_platform. Only keys that actually have a value are written, so a later touch cannot blank attribution captured earlier.


Attributing conversations to ads

The ads page can report leads two ways, chosen with the Lead source switch:

  • Meta — what Meta counted (messaging conversations started, leads).
  • CRM — conversations in ConnectGain that were traced back to a campaign, and what those became: deals, won, lost, revenue.

They legitimately differ. Meta counts a conversation started; the CRM counts a contact that stuck and a deal that closed. Neither is wrong, so the choice is the operator's rather than something reconciled automatically.

How a conversation is linked

the platform, called from the message webhooks. Two mechanisms, in order of trust, written to meta_ad_attributions (one row per conversation):

1. referral — confidence 1.0. Meta stamps click-to-message threads with the ad that was clicked:

Channel Where it arrives
WhatsApp Cloud message.referralsource_id is the ad id, plus a ctwa_clid
Messenger / Instagram the messaging event's referral, the first message's, or the Get-Started postback.referralad_id, ref, source: ADS
WhatsApp Lite never — a QR-paired personal account gets no referral

A referral whose source_type is post is deliberately not attributed: source_id then holds a post id, and treating it as an ad id would invent an ad that never ran.

2. first_message — inferred. No referral, so the conversation's first inbound message is matched against meta_ads.prefilled_message (the text a click-to-message ad puts in the composer, pulled by meta-ads-sync).

  • Both sides are normalised the same way — lowercased, punctuation and emoji stripped, whitespace collapsed. The SQL (normalize_ad_message_text, a generated column) and the TypeScript agree by construction; a match found in one must be reproducible in the other.
  • Scoring: exact 0.95, prefix 0.8, high token similarity 0.7/0.55.
  • Openers under two words ("hi") are ignored — they match everything.
  • Only the first inbound message is matched. Matching every message would attribute a thread to whichever ad its tenth message happened to resemble.

The hard part is ambiguity, not text. Several ads routinely share one prefilled sentence. When the text alone cannot separate them, the tiebreak is which of them was actually delivering impressions on the message's own day — an ad with no impressions cannot have produced it. Resolved that way, confidence caps at 0.75; if several were delivering, the biggest spender wins and confidence drops to 0.45 to say plainly that this was a guess between look-alikes. The row records candidate_count either way.

3. manual — a human correction. Outranks both and is never overwritten by a re-scan.

recordAdAttribution never downgrades: a weaker method, or the same method with lower confidence, is refused, so re-scanning cannot churn a row.

Backfill

meta-ads-attribute sweeps conversations the webhooks never attributed — ones that predate this, and ones whose ad had not been synced when they arrived. It runs automatically after each sync (fresh ads mean prefill text the sweeper has not seen) and can be called directly with { since, until, limit, dry_run }. dry_run: true returns sample matches without writing, which is the way to sanity-check the matcher against a real inbox before trusting it.

Reporting

get_ad_attribution_stats(since, until) returns per-campaign leads, deals, won, lost and revenue. Deals are joined through the attributed contact rather than a stored deal id, so a deal created any time after the conversation is picked up without a backfill. Won/lost classification mirrors the platform exactly (closed_won|won, closed_lost|lost) — the two must agree or the ads page and the sales report will disagree about the same deal.

Campaign tags, and navigating between the two sides

Attribution writes the campaign onto the records it matched as an ordinary tag, so a campaign can be filtered for anywhere tags already work:

campaign-<slugified name, ≤48 chars>-<last 6 digits of campaign id>

The id suffix is not decoration. Meta's boosted-post campaigns are named automatically and the names repeat verbatim — this account has two campaigns called [8/5/2026] Promoting https://facebook.com/appgain — so a name-only tag would collapse two campaigns into one filter and quietly double every figure read through it.

The tag is produced in two places that must stay in step: public.meta_campaign_tag(text, text) (the database writes the tags) and campaignTag in the platform (the report builds the links). the platform pins the shared expectations.

What applies the tags

Trigger Fires on Does
trg_sync_campaign_tags insert/update of meta_ad_attributions tags the conversation and every deal of its contact, and removes a superseded campaign tag no other attribution still claims
trg_deals_campaign_tags BEFORE INSERT ON deals with a contact a deal made after the conversation was attributed inherits the tag on the way in

backfill_campaign_tags (caller's org) is a set-based catch-up, run once by the migration and again after each attribution sweep. It is idempotent.

Contacts are deliberately not tagged: the inbox tag filter already matches conversation tags, and contacts carries far more triggers, so tagging it would buy nothing but webhook and activity-log noise.

Navigating

  • Campaign → records: the leads and deals figures on the campaigns table are links, and the expanded row carries both as buttons. They go to /inbox?tags=<tag> and /deals?tags=<tag> — both pages already read their tag filter from the URL. A tag link into the inbox also drops the open-only default — and outranks whatever status the tab was last left on — since a campaign's lost leads are exactly what such a link is followed to see.
  • Record → campaign: CampaignAttributionCard sits on a deal's details and in the inbox contact panel, showing the campaign, the ad, and how the link was decided (Meta's referral, a text match with its confidence, or a human). It links to /ads?campaign=<campaign_id>, which opens the report on that campaign alone — expanded, on the CRM lead source, over the last twelve months, and ignoring the zero-spend filter, because a campaign reached this way was reached for its outcomes rather than for its delivery.

Notes and limits

  • Deleted ads are not pruned. Structure rows are upserted, never deleted, so an ad removed in Ads Manager stays in meta_ads with its last-known status. Its historical insight rows remain valid and still count toward totals.
  • Ad-level daily data is large. the app pages explicitly rather than silently truncating at PostgREST's 1000-row cap.
  • Rates are recomputed from totals, never averaged from the daily CTR/CPC columns — averaging per-day rates weights a 3-impression day like a 300,000-impression one and matches nothing in Ads Manager.
  • Budgets are minor units of the account currency, as Meta returns them, and are not FX-converted.
  • FX rates are fetched per day, not per hour. Meta attributes spend in the ad account's timezone, so a day's boundary and its rate can be off by a few hours' worth of movement. Immaterial at daily reporting granularity.
  • Attribution by text is inference, not fact. The confidence and candidate_count on each row say how much to trust it, and the campaign table flags low-confidence matches. Referral-attributed rows carry no such doubt.
  • A conversation gets one attribution. A contact who clicks two different ads over two conversations is attributed to both, which is correct; a contact who clicks two ads and opens one thread is attributed to the first.
  • The rates provider is a free, unauthenticated public CDN. If it goes away, conversion degrades to the manual per-account fallback and then to native currency — no sync fails because of it.
  • Adset-level insights are supported by the schema and the hook but are not synced or surfaced yet.