> ## 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.

# Troubleshooting: Ads hub failures

> Diagnose the Ads hub's visible failure states: CHANNEL_UNAVAILABLE on every endpoint before connect, a test conversion event that never turns green, Post Conversion refusals with no match key, audience syncs truncated at 100,000 members, and Lead Ads import tallies.

# Troubleshooting: Ads hub failures

The **Ads** hub has five failure states that look broken but are each a
specific, expected gate: every endpoint answers `CHANNEL_UNAVAILABLE` until a
Meta Ad Account is connected, the test conversion panel only records sends
Meta confirmed, a conversion post without a match key is refused, an audience
sync caps at 100,000 members, and the Lead Ads paste box rejects text that is
not JSON. Each has a fixed cause and a fixed fix. Work the section that
matches your symptom; the field-level vocabulary for every surface on the hub
is in the [Ads hub reference](https://docs.orbit.devotel.io/ads/overview.md).

## Everything returns CHANNEL\_UNAVAILABLE

Until a connection is stored, every Ads endpoint except `GET /ads/status`
returns the same envelope:

```json theme={null}
{
  "error": {
    "code": "CHANNEL_UNAVAILABLE",
    "message": "Meta Ad Account not connected. Go to Settings > Channels to connect.",
    "status": 400
  }
}
```

On the hub itself this state renders as the **Connect Meta Ad Account** card
on all four tabs. That card is the *not-connected* state. A failed status
read is a different state and renders differently:

| Symptom | Cause | Fix |
| - | - | - |
| Every tab shows the connect card; every non-status endpoint returns `CHANNEL_UNAVAILABLE`. | No Meta Ad Account is connected for this organization. | An **owner or admin** connects once via **Settings > Channels > Meta Ads** (the card's **Connect via Settings** button deep-links there). Member seats are read-only on this hub and keep seeing the card until an owner or admin connects. |
| You connected, but the hub still shows the connect card. | `GET /ads/status` still reads `connected: false`: the OAuth callback never finished, or the stored token no longer decrypts (the envelope then says the access token could not be decrypted and to reconnect). | Re-run the connect flow end to end in one pass, including the Meta redirect back. Then confirm `GET /ads/status` returns `connected: true` with your `ad_account_id`. |
| A tab shows a real error with a **Try again** button instead of the connect card. | The status read itself failed with a 401, 403, or 500. That is a transient read failure, not a disconnected account. | Click **Try again**. The hub deliberately never renders a failed status read as "not connected", so do not disconnect or reconnect in response to it. |
| Reads work, but every write (connect, campaign create/update/delete, sync, conversion post, lead ingest) is refused. | Write operations on the Ads hub are owner/admin-only; member seats can read campaigns, audiences, and attribution but cannot mutate anything. | Have an owner or admin run the write. |
| You want to stop the connection. | Disconnect halts live data from Meta and disables click-to-chat campaigns, and recovery means a full Meta re-authorization. The banner's **Disconnect** button therefore opens a dialog that only enables its confirm button after you type `DISCONNECT` (all caps, case-sensitive). | Type the phrase only when you mean it; `DELETE /ads/disconnect` fires only after the phrase matches. |

If `CHANNEL_UNAVAILABLE` persists after a connect that `GET /ads/status`
confirms with `connected: true`, you are calling a different organization
than the one you connected (API key or session belongs to another workspace).

## The conversion test event never turns green

**Send Test Event** on the Conversions tab posts a test event to the Meta
Conversions API. The Recent Test Events panel next to it is session-scoped
and only ever logs a send Meta confirmed with `events_received > 0`. A
rejected send therefore never turns green and never appears in the panel.
That is the panel working as designed, not a rendering bug:

| Symptom | Cause | Fix |
| - | - | - |
| **Send Test Event** returns `CHANNEL_UNAVAILABLE` ("Conversions API not configured. Connect your Meta Ad Account and set a dataset ID."). | The connection exists but no Dataset ID is stored, or the account is not connected at all. | Set the Dataset ID once with `PUT /ads/conversions/dataset` (owner/admin), then re-send. The id is the Meta Conversions API dataset (Events Manager > Datasets), e.g. `{"dataset_id": "1234567890123456"}`. |
| The send returns 200 but the panel stays empty and nothing turns green. | Meta acknowledged the request with `events_received: 0` — the event was rejected counterside (wrong dataset for the ad account, or the dataset not attached to the account). The panel logs only `events_received > 0`. | Confirm the stored Dataset ID belongs to the connected ad account in Meta Events Manager, correct it with `PUT /ads/conversions/dataset`, and send again. A corrected send that Meta receives shows up in the panel immediately. |
| The panel showed a green entry earlier and is now empty. | The log is session-scoped: it records sends made in this dashboard session only, and a reload starts it empty. | Empty after reload means "no test sent this session", not "the previous test failed". Send a new test event to re-verify. |

A confirmed send renders the received count (for example "1 received") in the
panel. If repeated sends across several minutes all return 200 with nothing
logged, the dataset/ad-account pairing at Meta is the thing to inspect; the
flash of the connection is not.

## 422 on Post Conversion

`POST /ads/conversions` requires at least one match key in `user_data`:
`ctwa_clid` (the deterministic Click-to-WhatsApp click id), or `phone`,
`email`, `external_id` (Orbit SHA-256 hashes these before they leave the
platform; send them raw). A body with none of the four is refused with a
deterministic `VALIDATION_ERROR` envelope (HTTP 400 on this endpoint; the
other Ads writes use 422) whose `details` name the missing match key. Never
retry it verbatim; add a key and send once.

Match-key menu:

| Key | When to use |
| - | - |
| `ctwa_clid` | The conversation started from a click-to-WhatsApp ad. The id arrives on the inbound message's referral metadata; replaying it lets Meta join the conversion to the exact ad for closed-loop ROAS. |
| `phone` | No ad click id, but you have the customer's number in E.164. Hashed server-side before delivery to Meta. |
| `email` | Same as phone, for email identifiers. |
| `external_id` | Your own stable customer id, when neither of the above matches what Meta knows about the user. |

Worked body with the deterministic key:

```json theme={null}
{
  "event_name": "Lead",
  "messaging_channel": "whatsapp",
  "user_data": {
    "ctwa_clid": "eyJhZGlkIjoiMjM4MTI1NzY1NDMyMSJ9"
  },
  "custom_data": {
    "lead_event_source": "whatsapp_chat"
  }
}
```

Worked body with a hashed identifier (send the raw value; the hashing happens
before the event leaves Orbit):

```json theme={null}
{
  "event_name": "Purchase",
  "messaging_channel": "whatsapp",
  "user_data": {
    "phone": "+4915123456789"
  },
  "custom_data": {
    "value": 120.5,
    "currency": "EUR"
  }
}
```

A refusal you see here is payload-shaped, not connection-shaped: if the
envelope instead says `CHANNEL_UNAVAILABLE`, the account or dataset is the
problem and the previous two sections apply.

## Audience sync reports truncated: true

`POST /ads/audiences/:audienceId/sync` resolves the linked segment's members
and uploads them hashed, capped at 100,000 members per sync. Past the cap the
response carries `truncated: true`:

```json theme={null}
{
  "data": {
    "audience_id": "2381000000000001",
    "segment_id": "seg_01JXK2M4N6P8Q0R2S4T6V8W0X",
    "mode": "add",
    "matched": 100000,
    "sent": 100000,
    "truncated": true
  }
}
```

| Symptom | Cause | Fix |
| - | - | - |
| `truncated: true` with `mode: "add"`. | The segment holds more than 100,000 members with an email or phone; only the first 100,000 were uploaded for targeting. | Split the segment into cohorts below the cap (for example by country or recency), create one audience per cohort, and sync each. |
| `truncated: true` with `mode: "remove"`. | Suppression removed only the first 100,000 members; the remainder of the segment is still in the audience and can still be targeted. | Treat this as urgent: split the segment and run `mode: "remove"` per cohort until every cohort syncs untruncated. |
| `matched` is far below the segment's reported size, no truncation. | Rows need at least one of email or phone to count as a member; contacts with neither are excluded, as are deleted contacts. | Preview the segment and check how many contacts carry an identifier; that is the syncable ceiling. |

`matched` is the members resolved from the segment, `sent` the members handed
to Meta. Sync metadata (`last_synced_at`, `last_sync_count`) on the audience
row updates after each successful sync.

## Lead Ads paste rejected / import tallies

The Lead Ads tab accepts the raw `leadgen` webhook batch Meta delivered to
your endpoint. The paste box checks one thing client-side: the text must
parse as JSON. Anything else is rejected in the browser with "That doesn't
look like valid JSON. Paste the Meta leadgen webhook payload." and nothing is
sent. Valid JSON that carries no `leadgen` changes is sent and answers
`received: 0`.

After a real import, the **Last import** panel shows the tally:

| Field | Meaning |
| - | - |
| `received` | `leadgen` lead ids extracted from the pasted envelope. `0` means the envelope carried no leadgen changes. |
| `fetched` | Leads whose full answers were successfully fetched from Meta's Graph and mapped to a contact draft. |
| `fetch_failures` | Leads whose Graph fetch or mapping failed. One bad lead never drops the rest of the batch; retry the batch later if this is nonzero. |
| `skipped_no_contact` | Fetched leads with neither an email nor a phone — never imported, because they would create empty contacts. |
| `created` | New contacts created. |
| `updated` | Existing contacts enriched (a re-submitted lead merges into the contact it matches). |
| `duplicates` | Rows the import service counted as duplicates under its merge strategy. |

`received` always equals `fetched` + `fetch_failures` + `skipped_no_contact`;
the created/updated/duplicates split applies to the `fetched` drafts. Every
imported lead goes through the same dedupe, consent, and audit path as a CSV
import, so a lead that appears under `updated` or `duplicates` was not lost —
it matched a contact you already have.

## See also

* [Ads hub reference](https://docs.orbit.devotel.io/ads/overview.md) — the field-level vocabulary for the
  connection cards, the four tabs, and the role matrix.
* [Ads console walkthrough](https://docs.orbit.devotel.io/guides/ads-console.md) — the object-verb map of the
  hub, including its troubleshooting notes.
* [Ads attribution and lead ingestion](https://docs.orbit.devotel.io/concepts/ads-attribution-lead-ingestion.md) —
  the first-touch attribution model behind `ctwa_clid` and lead ingestion.
* [Ads hub: first Meta connect, first attributed lead](https://docs.orbit.devotel.io/guides/ads-hub-overview.md) —
  the narrative connect-to-first-lead walkthrough.
* [Troubleshooting hub](https://docs.orbit.devotel.io/reference/troubleshooting-hub.md) — the index of every
  runbook.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.