> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Your first attributed lead, end to end

> Walk the Meta ads loop from zero to one attributed ROAS row — connect the ad account, register a lead path, build the segment, launch PAUSED-to-ACTIVE, sync the Custom Audience, ingest one entry, forward one conversion, and read attribution back.

# Your first attributed lead, end to end

This guide walks the full Meta ads loop in order, once, from zero to a readable attribution row: connect the account, register how leads arrive, build a segment, launch a campaign, sync the audience, ingest one entry, forward one conversion, and read ROAS back. The [Ads hub overview](/guides/ads-hub-overview) explains what the hub owns; the [console walkthrough](/guides/ads-console) details each tab; the [API-driven loop](/guides/ads-activation) covers the same ground over curl. This page is the linear first run through all of it — open one section in one sitting.

Prerequisites: owner or admin role on your API key (every write below needs it), and a Meta Business account with an ad account and a Facebook app you can authorize.

## 1. Connect the Meta ad account

Open **Settings > Channels > Meta Ads** and choose **Connect**. Sign in with Facebook, pick the ad account in Meta's picker, and Orbit exchanges the OAuth code for a long-lived token, encrypts it at rest, and records the account's billing currency. One ad account connects per organization.

Back on the **Ads** page, the connect banner turns green and names the connected account id. Validate the link over the API:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/ads/status \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

A working connection returns the account id and `currency`. Until it does, every other Ads endpoint returns `CHANNEL_UNAVAILABLE` — fix this before anything downstream.

Disconnect is behind a typed phrase on purpose: the banner's **Disconnect** dialog asks you to type **`DISCONNECT`** (all-caps, case-sensitive) before the button enables, because it stops live data flow from Meta and disables campaigns. There is no accidental disconnect, and no silent one.

## 2. Register how leads arrive

Leads enter two ways. Register the path your campaign uses:

**Path A — click-to-chat.** The contact taps the ad into a WhatsApp, Messenger, or Instagram Direct conversation. The first inbound message carries Meta's referral block — the ad id and the `ctwa_clid` click id — which becomes the attribution anchor. Nothing to register; the inbound message carries the anchor. Capture `ctwa_clid` at entry time, because the console cannot reconstruct it later.

**Path B — lead-form ingestion.** Meta Lead Ads forms arrive on your webhook endpoint, and you paste the envelope into the **Lead Ads** tab:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ads/leads/ingest \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"object":"page","entry":[{"id":"<page-id>","changes":[{"field":"leadgen","value":{"leadgen_id":"<lead-id>"}}]}]}'
```

The extraction is deliberately narrow: only `leadgen` changes are pulled from the envelope, each `leadgen_id` is fetched from the Meta Graph (fields `field_data,created_time,ad_id,ad_name,adset_id,campaign_id,campaign_name,form_id`), and the contact is upserted through the same dedupe-consent-audit path as a CSV import. A lead with neither email nor phone is skipped (`skipped_no_contact`), and per-lead fetch failures tally into the result without aborting the batch. Path B leads attribute later through hashed identity — no click id ever existed for them.

## 3. Build the segment and preview its size

Build the audience before any spend. The recipe lives in [CDP audiences: build segments](/guides/cdp-segments); for ads, two properties carry the weight: a filter over facts (attributes, events, computed traits) so the sync references a live `segment_id`, and `auto_refresh` so membership tracking survives launches.

Size the filter before saving it:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments/preview \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "filter": { /* your segment filter AST */ } }'
```

The response returns `match_count` — sizing only, nothing persisted. A `0`-match filter that reaches spend is a paid no-op; preview first.

## 4. Create the campaign — PAUSED first, ACTIVE after review

On **Ads > Ad Campaigns > Create campaign**, set the name, daily budget (rendered in the account's billing currency), destination (`WHATSAPP`, `MESSENGER`, or `INSTAGRAM_DIRECT` — the only three values), and ad text. Optional over the API: objective (`OUTCOME_LEADS` default), `countries[]`, and age bounds.

New campaigns are created `PAUSED` — a server-side default you cannot override at create. Review the campaign in Meta, then flip it to `ACTIVE` from the campaign row's **Resume** action. Because Meta ad review runs while the campaign is paused, a clean review means delivery starts ordered, not retro-fixed.

## 5. Sync the segment as a Custom Audience

On **Ads > Audiences**, create an audience linked to the segment, then **Sync now:**

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ads/audiences \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "name": "First attributed lead — targeting", "segment_id": "seg_first_leads" }'

curl -X POST https://api.orbit.devotel.io/api/v1/ads/audiences/aud_abc/sync \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "add" }'
```

Two modes: `add` ships membership for targeting; `remove` suppresses those contacts. Email and phone are SHA-256 hashed before they leave. The sync pages 10,000 contacts per Meta call and caps at 100,000 members; past the cap the response marks `truncated: true` — narrow the segment and re-sync. Before a segment feeds an ad audience, run the [audience consent inspector](/guides/audience-consent-inspector) against it — consent to use a contact's data on ad platforms sits on your side of the line.

## 6. Ingest one entry

The attribution anchor lands when a contact enters. Two ways that first row writes:

* **Click-to-chat** — the contact taps the campaign ad; the first inbound WhatsApp/Messenger/Instagram Direct message carries the referral block with the ad id and `ctwa_clid`. Entry records at that moment.
* **Lead form** — the contact submits the Lead Ads form; paste the envelope and ingest (Section 2). Entry records at ingest.

Either path writes the same attribution anchor. Keep the `ctwa_clid` if one exists — it is the deterministic join the conversion below closes on.

## 7. Forward one conversion

Set the dataset id once (**Conversions > Configuration**, or `PUT /ads/conversions/dataset`), then fire the test path before live traffic:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ads/conversions/test \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

Only after the test panel confirms a `received` row, post the real event:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ads/conversions \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "Lead",
    "user_data": { "ctwa_clid": "<click-id-from-entry>" },
    "custom_data": { "value": 49.99, "currency": "USD" }
  }'
```

The event needs at least one match key — the `ctwa_clid`, or a hashed `phone`, `email`, or `external_id`. Without one, Meta cannot attribute the event and the API rejects with `422`. The click id joins deterministically; a hashed-identity join is probabilistic on Meta's side, so prefer the click id when you captured one at entry (Section 6). Phone, email, and external id are SHA-256 hashed server-side before they leave; the `ctwa_clid` travels as-is, because it is Meta's own click id.

## 8. Read it back

The Ads console deliberately has no attribution tab — the API is the report. Read it:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/ads/ctwa/attribution?window_days=30" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

Expected response shape: `reporting_currency`, `window_days`, totals (`total_entry_count`, `total_unique_contacts`, `total_attributed_revenue_cents`, `total_unconvertible_revenue_cents`, `total_spend_cents`, `roas`, `roi_pct`, `cost_status`, `spend_available`), and an `ads[]` array with one row per entry ad: `ad_id`, first/last seen, `entry_count`, `unique_contacts`, `attributed_revenue_cents`, `unconvertible_revenue_cents`, `spend_cents`, `roas`, `roi_pct`, `cost_status`.

Attribution is first-touch: the earliest captured referral fixes the entry ad, and per-ad contact counts partition cleanly. Revenue in a currency with no caller-supplied FX rate surfaces in `unconvertible_revenue_cents` rather than blending at 1:1. And when no spend is fetchable — no account connected, or the Meta spend read failed — the row still returns entry attribution with `roas: null` and `cost_status: "no_spend"`, so the window never silently mixes numerator and denominator periods.

## 9. Troubleshooting

* **`CHANNEL_UNAVAILABLE` on every call.** No connection exists. Connect (Section 1) and re-read `/ads/status` before anything downstream.
* **422 on the conversion post.** No match key — supply `ctwa_clid`, or a `phone`/`email`/`external_id` in `user_data`. The console's **Post Conversion** button stays disabled until one is present.
* **Empty-segment sync.** The API filters segment membership to rows with an email or phone before calling Meta; a segment over filters that match no identifier ships an empty payload. Re-check the segment filter against the preview (Section 3).
* **Test event logs nothing.** The recent-test-events panel only logs sends Meta confirmed with `events_received > 0`; a silent reject never becomes a false green. If the test event posts but nothing logs, check the dataset id at Conversions > Configuration.
* **Attribution row is empty.** The campaign never reached `ACTIVE`, or no entry landed inside `window_days`. Check the campaign status and widen the window (1–365; default 30).
* **`truncated: true` on the audience sync.** Membership exceeded the 100,000-member cap. Narrow the segment and re-sync.

## See also

* [Ads hub overview](/guides/ads-hub-overview) — what the hub owns and which guide answers which job
* [Ads API reference](/api-reference/ads) — endpoint schemas for every call above
* [Ads attribution and lead ingestion (concepts)](/concepts/ads-attribution-lead-ingestion) — the first-touch model this loop implements
* [CDP audiences: build segments](/guides/cdp-segments) — the segment recipe
* [Audience consent inspector](/guides/audience-consent-inspector) — verify consent before a segment feeds an ad audience
