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.
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 / impressionsover the whole window, never averaged from the per-dayctrcolumn. - Cost per message —
spend / messaging conversations started, the click-to-message metric. The conversation count is denormalised out ofactionsintometa_ad_insights.messaging_conversations, preferringonsite_conversion.messaging_conversation_started_7d(what Ads Manager shows as "Messaging conversations started") and falling back tototal_messaging_connectionthenmessaging_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/adaccountssee 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:
- A role on the ConnectGain Facebook app (App Dashboard → App roles).
- 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 ondate_startmeta_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_clampedso 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_errorand the run continues. A Graph code190is rewritten to a "reconnect this ad account" message. verify_jwt = false+authenticateRequest, so a cron or service-role caller can drive it by passingorganization_idin the body (same pattern asmeta-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_dataand passes them through asadAttribution.
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.referral — source_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.referral — ad_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:
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:
CampaignAttributionCardsits 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_adswith 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
confidenceandcandidate_counton 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.