Ads hub: first Meta connect, first attributed lead
The Ads section of the dashboard manages the Meta click-to-chat ads loop: connect one Meta Ad Account, run campaigns whose destination is a WhatsApp/Messenger/Instagram Direct conversation, activate CDP segments as hashed Custom Audiences, ingest Meta lead-form submissions as contacts, and read first-touch ROAS back per entry ad. This page walks that loop on the hub itself, from the empty first paint to one attributed lead. The endpoint schemas live in the Ads API reference; the attribution model is ads attribution and lead ingestion (concepts). Every control below is tenant-owned: you connect your own Meta account, your segments define the audiences, and nothing renders until you put data in. One Meta Ad Account connects per organization, and the orientation roles are simple — all member seats read campaigns, audiences, and the attribution API; every write (connect/disconnect, campaign or audience create/update/delete, conversions configuration/test/post, lead ingestion) needs owner or admin.1. First paint — what renders before anything is connected
Open Ads in the dashboard and it loads four tabs: Ad Campaigns, Conversions, Audiences, and Lead Ads. On first paint, every one of them queries the connection first —GET /ads/status — and until a Meta Ad Account is connected, each tab renders only the Connect Meta Ad Account card: a “how it works” list, a Connect via Settings button that deep-links to the Meta Ads connector card under Settings > Channels, and an Early access badge while the channel is still rolling out. There is no hidden data behind the empty state — no cached campaigns, no partial audiences. The hub deliberately stays empty until Connect, because every Ads endpoint except /ads/status returns CHANNEL_UNAVAILABLE while no connection exists.
While a connection exists, the same four tabs swap in their real surfaces:
- Ad Campaigns — a success banner naming the connected
ad_account_id, the campaign table (name, status, destination, daily budget in the account’s billing currency, impressions, clicks, conversations), search + status filter, and a Create campaign action. - Conversions — the Meta Conversions API (CAPI) card: dataset Configuration, Test Conversion Event with a session-scoped recent-test-events log, and Post Conversion for real attributed events.
- Audiences — Custom Audiences linked to CDP segments, with last-synced timestamp and member counts, and create/sync/delete actions.
- Lead Ads — a paste box for the Meta
leadgenwebhook batch, plus a “Last import” tally (received / created / updated / duplicates / skipped / fetch failures).
/ads/status itself fails (a 401/403/500), the tab shows a real error with a Try again retry — not the connect card — so a failed status read never masquerades as “not connected yet.” And if a tile is empty after Connect, the FAQ at the bottom of this page names the cause for each of the four tiles.
2. Connect Meta Ads — OAuth walkthrough and the DISCONNECT guard
The OAuth flow runs once per organization:- From the Ads hub’s connect card click Connect via Settings (or go straight to Settings > Channels and find the Meta Ads connector card). An Early access note appears on both while the channel rolls out — the connect works today; the badge only flags that rollout is not finished.
- Click Connect Ad Account on the connector card. Meta’s OAuth dialog opens: sign in with the Facebook account that administers the business, review the permissions request, and pick the ad account in Meta’s account picker.
- Meta redirects back with an authorization
code. The callback page exchanges that code overPOST /ads/oauth/exchange, Orbit trades it for Meta’s long-lived token, encrypts it at rest, and records the connectedad_account_idand the account’s billingcurrency— that currency drives how budgets, spend, and ROAS render everywhere downstream. - Validate from the hub: back on Ads > Ad Campaigns, the connect card becomes a green Meta Ad Account Connected banner naming the account id. Over the API:
connected: true, the ad_account_id, and currency. Fix this before anything downstream — every other Ads endpoint returns CHANNEL_UNAVAILABLE until it succeeds.
Disconnect is a typed-phrase guard. Because disconnecting stops live data flow from Meta and disables click-to-chat campaigns (reconnecting means a fresh Meta re-authorization), the banner’s Disconnect button opens a dialog that requires typing DISCONNECT — all-caps, case-sensitive — before the confirm button enables. There is no accidental disconnect and no silent one; the DELETE only fires after the phrase matches:
3. Decision map — which tab for which job
Attribution has no tab by design:
/ads/ctwa/attribution is the report — read it over the API and render it wherever you need it (your BI, a spreadsheet, the marketing mix model). The console never re-derives what the API computes.
4. The first-lead loop, end to end
Five steps close the loop from an empty hub to one attributed lead:- Connect — Sections 1–2 above: OAuth at Settings > Channels > Meta Ads, green banner on the hub,
/ads/statusreturnsconnected: truewithad_account_idandcurrency. - Lead enters — one of two paths. Path A, click-to-chat: create a campaign on Ad Campaigns (destination
WHATSAPP,MESSENGER, orINSTAGRAM_DIRECT). It is createdPAUSED— a server-side default you cannot override at create — so review it in Meta and resume it toACTIVE. When a contact taps the ad, the first inbound message arrives carrying Meta’s referral block: the ad id and thectwa_clidclick id. That block is the attribution anchor; capture it at entry time because nothing can reconstruct it later. Path B, lead-form ingestion: the contact submits a Meta Lead Ads form; Meta posts aleadgenwebhook to your endpoint; paste the envelope into Lead Ads (orPOST /ads/leads/ingest). Eachleadgen_idis fetched from Meta’s Graph and upserted through the same dedupe-consent-audit path as a CSV import. Path B leads attribute later by hashed identity — no click id ever existed for them. - The entry records with its anchor — either path writes an entry keyed to the entry ad. Path A anchors deterministically on
ctwa_clid; Path B anchors on the lead’s contact identifiers (email/phone), hashed before any onward send. - Attribution accumulates — the report is first-touch: the earliest captured referral fixes the contact’s entry ad, per-ad contact counts partition cleanly (summing across ads never double-counts), and orders placed before the ad tap are excluded. Revenue in a currency with no caller-supplied FX rate surfaces in
unconvertible_revenue_centsrather than blending at 1:1. - Convert and close the loop — when the lead converts, forward the outcome to Meta: Conversions > Post Conversion (or
POST /ads/conversions) with at least one match key — thectwa_clid(deterministic join on Meta’s side), or aphone/email/external_id(SHA-256 hashed server-side; probabilistic there). Run Test Conversion Event before the first real post. Without a match key the API rejects with 422 — Meta cannot attribute the event.
5. Worked scenario — first connect to one ROAS row
One scenario from empty to readable: brand-new organization, one campaign, one lead, one CAPI conversion, one attribution read. Connect. Complete Section 2. The banner turns green; validate:25.00 (labeled in the account currency), destination WHATSAPP — the only alternatives are MESSENGER and INSTAGRAM_DIRECT, and no other network exists in the hub (see Ads: scope and feature boundaries). The row lands with status PAUSED. Review it in Meta Ads Manager, then Resume it from the row menu — the badge flips to ACTIVE.
One lead enters. A contact taps the live ad into a WhatsApp conversation. The first inbound message carries the referral block — the originating ad plus ctwa_clid = ARAd-example.... Save that click id; the conversion below joins on it.
Forward one conversion. Set the dataset first (Conversions > Configuration), then verify with Send Test Event — proceed only when the recent-test-events panel logs a received event. Then post the real event with the click id as match key:
GET /ads/ctwa/attribution?window_days=30 returns reporting_currency, window_days, totals, and one row per entry ad. Decrypted into plain values, a first scenario’s single row reads like:
That row is the loop working: one entry (count and unique both 1), $49.99 of credited revenue against $12.40 of Meta spend fetched over the same 30-day window, ROAS 4.03. If the spend fetch had failed, the row would still return with
roas: null and cost_status: "no_spend" — entry attribution never disappears behind a spend read.
6. FAQ — the four “why is my tile empty?” cases
Why is the Ad Campaigns tile empty after Connect? The connection exists but no campaign was ever created, or every campaign was deleted. Create one (Section 3) — it startsPAUSED and appears in the table immediately. If the tile is empty but the filters read ” filters active”, a search or status filter excluded the rows — clear the filters. If the tab shows the connect card instead, /ads/status still returns connected: false: re-run the OAuth exchange (Section 2) and re-read status.
Why is the Conversions tile empty / why does the test log nothing? The Conversions tab itself always renders once connected — “empty” here means the Recent Test Events panel shows nothing. Two causes: no test event was ever sent this page session (the log is session-scoped; there is no server-side test history), or a send ran but the panel filtered it — the panel only logs sends Meta confirmed with events_received > 0, so a send that logged nothing was posted but rejected counterside, almost always a missing or wrong Dataset ID (Conversions > Configuration, or PUT /ads/conversions/dataset). A rejected send never becomes a false green.
Why is the Audiences tile empty? No Custom Audience was created yet (create one and link a CDP segment), or every linked segment matches zero contacts. Before a segment feeds an ad audience, size its filter with the segment preview (POST /contacts/segments/preview — match_count: 0 means a sync ships an empty payload, because the API filters membership to rows with an email or phone before calling Meta). Also remember the 100,000-member cap: past it, a sync reports truncated: true — narrow the segment and re-sync. Consent is tenant-side: run the audience consent inspector before targeting, and use remove-mode sync for suppression.
Why is the Lead Ads tile empty? It only shows data after an import — the “Last import” tally appears once you paste a leadgen webhook batch. If an import ran and still shows nothing, read the tally: received: 0 means the envelope carried no leadgen changes (only leadgen fields are extracted); skipped_no_contact means the lead had neither email nor phone and was skipped rather than creating an empty contact; fetch_failures counts per-lead Graph-fetch failures (the batch still processes the other leads). Also check the JSON is valid before import — the paste box rejects unparseable payloads client-side.
Why is there no attribution tab? By design — Ads console walkthrough covers it: GET /ads/ctwa/attribution?window_days= is the report, render it wherever you need it.
Why no Google or TikTok ads here? Scope — read Ads: scope and feature boundaries. The hub answers inside Meta’s click-to-chat loop and declines outside it, because Meta’s conversation surfaces are the only network that exposes the deterministic ctwa_clid join.
Where the in-depth docs live
- Ads console walkthrough — the object-verb map, OAuth connect, PAUSED-at-create campaigns, CAPI test-then-post, Lead Ads ingestion, troubleshooting.
- Your first attributed lead, end to end — the same loop step by step in tab order: connect, register a lead path, build the segment, launch, sync, ingest, convert, read.
- Meta Ads: segment to campaign to attribution — the end-to-end loop over the API with copy-pasteable curl.
- Ads: scope and feature boundaries — the network/destination/objective boundary, tile-by-tile, and its FAQ.
- Ads attribution and lead ingestion (concepts) — the first-touch attribution model this hub implements.
- Ads API reference — every endpoint behind the tiles.