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

# sync_audiences

> sync_audiences task — upload hashed first-party CRM audiences to AdCP seller accounts for retargeting, suppression, and lookalike expansion. Supports match status tracking.

Manage first-party CRM audiences on a seller account. Upload hashed customer lists, check matching status, and reference the resulting audiences in [`create_media_buy`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy) targeting overlays for explicit retargeting or suppression.

Audiences are distinct from [signals](/dist/docs/3.3.0-beta.0/signals/): signals are named targeting dimensions discovered through product signal options, provider-published signal definitions, or [`get_signals`](/dist/docs/3.3.0-beta.0/signals/tasks/get_signals); audiences are data you own and upload. Use `audience_include` to target only members of an uploaded list. `audience_include` is a hard constraint — only users on the list are eligible. A signal returned by `get_signals` is not a synced audience ID and instead belongs in [`signal_targeting_groups`](/dist/docs/3.3.0-beta.0/media-buy/advanced-topics/targeting#signal_targeting_groups). To find new users *similar to* an audience (lookalike expansion), describe that intent in your campaign brief — the seller handles expansion strategy. Note: lookalike intent expressed in the brief cannot be verified through the protocol; confirm via seller-side reporting.

**Response Time**: Upload accepted in \~1–2s. Per-audience matching is asynchronous — the task remains active until matching completes (1–48 hours depending on the seller). Configure `push_notification_config` to receive a webhook when the audience is ready. Sellers whose ingestion pipeline cannot return per-audience results in the synchronous response (batch ingestion, governance-gated upload, clean-room flows) MAY respond with an operation-level submitted task envelope — see [Response shapes](#response-shapes).

**Request Schema**: [`/schemas/3.3.0-beta.0/media-buy/sync-audiences-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/media-buy/sync-audiences-request.json)
**Response Schema**: [`/schemas/3.3.0-beta.0/media-buy/sync-audiences-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/media-buy/sync-audiences-response.json)

## Quick Start

Upload a customer list and check its status:

```javascript JavaScript theme={null}
import { testAgent } from "@adcp/sdk/testing";
import { SyncAudiencesResponseSchema } from "@adcp/sdk";
import { createHash } from "crypto";

const hashEmail = (email) =>
  createHash("sha256").update(email.toLowerCase().trim()).digest("hex");

const hashPhone = (e164Phone) =>
  createHash("sha256").update(e164Phone).digest("hex");

const result = await testAgent.syncAudiences({
  account: { account_id: "acct_12345" },
  audiences: [
    {
      audience_id: "existing_customers",
      name: "Existing customers",
      add: [
        { external_id: "crm_1001", hashed_email: hashEmail("alice@example.com") },
        { external_id: "crm_1002", hashed_email: hashEmail("bob@example.com"), hashed_phone: hashPhone("+12065551234") },
      ],
    },
  ],
});

if (!result.success) {
  throw new Error(`Request failed: ${result.error}`);
}

const validated = SyncAudiencesResponseSchema.parse(result.data);

// Three-shape discriminated union: errors | submitted | audiences
if ("status" in validated && validated.status === "submitted") {
  // Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook
  console.log(`Sync queued as task ${validated.task_id}: ${validated.message ?? ""}`);
} else if ("errors" in validated && validated.errors && !("audiences" in validated)) {
  throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`);
} else if ("audiences" in validated) {
  for (const audience of validated.audiences) {
    console.log(`${audience.audience_id}: ${audience.action} (${audience.status ?? "n/a"})`);
    if (audience.status === "ready") {
      console.log(`  Matched ${audience.matched_count} of ${audience.uploaded_count} members (this sync)`);
    }
  }
}
```

```python Python theme={null}
import asyncio
import hashlib
from adcp.testing import test_agent

def hash_email(email: str) -> str:
    return hashlib.sha256(email.lower().strip().encode()).hexdigest()

def hash_phone(e164_phone: str) -> str:
    return hashlib.sha256(e164_phone.encode()).hexdigest()

async def main():
    result = await test_agent.simple.sync_audiences(
        account={'account_id': 'acct_12345'},
        audiences=[{
            'audience_id': 'existing_customers',
            'name': 'Existing customers',
            'add': [
                {'external_id': 'crm_1001', 'hashed_email': hash_email('alice@example.com')},
                {'external_id': 'crm_1002', 'hashed_email': hash_email('bob@example.com'), 'hashed_phone': hash_phone('+12065551234')},
            ]
        }]
    )

    # Three-shape discriminated union: errors | submitted | audiences
    if getattr(result, 'status', None) == 'submitted':
        # Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook
        print(f"Sync queued as task {result.task_id}: {getattr(result, 'message', '') or ''}")
        return

    if getattr(result, 'errors', None) and not getattr(result, 'audiences', None):
        raise Exception(f"Operation failed: {result.errors}")

    for audience in result.audiences:
        status = getattr(audience, 'status', 'n/a')
        print(f"{audience.audience_id}: {audience.action} ({status})")
        if status == 'ready':
            print(f"  Matched {audience.matched_count} of {audience.uploaded_count} members (this sync)")

asyncio.run(main())
```

## Request Parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `account` | [account-ref](/dist/docs/3.3.0-beta.0/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. |
| `audiences` | [Audience](#audience-object)\[] | No | Audiences to sync. When omitted, the call is discovery-only and returns all existing audiences without modification. |
| `delete_missing` | boolean | No | When true, buyer-managed audiences on the account not in this request are removed (default: false). Does not affect seller-managed audiences. Do not combine with an omitted `audiences` array or all buyer-managed audiences will be deleted. |

### Audience Object

| Field | Type | Required | Description |
| - | - | - | - |
| `audience_id` | string | Yes | Buyer's identifier for this audience. Used to reference the audience in targeting overlays. |
| `name` | string | No | Human-readable name |
| `delete` | boolean | No | When true, delete this audience from the account entirely. All other fields are ignored. |
| `add` | [AudienceMember](#audience-member)\[] | No | Members to add to this audience. Mutually exclusive with `source`. |
| `remove` | [AudienceMember](#audience-member)\[] | No | Members to remove from this audience. If the same identifier appears in both `add` and `remove`, remove takes precedence. Mutually exclusive with `source`. |
| `source` | [AudienceSource](#external-audience-sources-experimental) | No | **Experimental.** External source reference — the seller ingests membership from a shared dataset or vendor-distributed segment instead of inline member deltas. See [External audience sources](#external-audience-sources-experimental). |
| `consent_basis` | string | No | GDPR lawful basis: `consent`, `legitimate_interest`, `contract`, or `legal_obligation`. Required by some sellers in regulated markets. |

### Audience Member

Every member requires an `external_id` (buyer-assigned stable identifier) plus at least one matchable identifier. Hash all values with SHA-256 before sending — normalize emails to lowercase+trim, phone numbers to E.164 format (e.g. `+12065551234`).

| Field | Type | Description |
| - | - | - |
| `external_id` | string | **Required.** Buyer-assigned stable identifier for this member (e.g. CRM record ID, loyalty ID). Used for deduplication, removal, and cross-referencing with buyer systems. |
| `hashed_email` | string | SHA-256 hash of lowercase, trimmed email (64-char hex) |
| `hashed_phone` | string | SHA-256 hash of E.164-formatted phone number (64-char hex) |
| `uids` | UID\[] | Universal IDs: `type` (rampid, uid2, maid, etc.) + `value` |

Providing multiple identifiers for the same person improves match rates. Composite identifiers (e.g. hashed first name + last name + zip) are not yet standardized — use `ext` for platform-specific extensions.

**Identifier support varies by seller**: Check [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.0/protocol/get_adcp_capabilities) → `media_buy.audience_targeting.supported_identifier_types` and `media_buy.audience_targeting.supported_uid_types` before sending. MAID support is not universal (LinkedIn does not accept MAIDs; iOS IDFA requires App Tracking Transparency consent). The `media_buy.audience_targeting.matching_latency_hours` range and `media_buy.audience_targeting.minimum_audience_size` in capabilities are also seller-specific.

**Activation paths beyond inline push**: Inline members are one activation method. Sellers declare which paths they support — AdCP push, dataset sharing, clean rooms, platform distribution — via [`audience_activation`](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/media-products#audience-activation) on products and `media_buy.audience_targeting.supported_activation_methods` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.0/protocol/get_adcp_capabilities). Filter discovery with `filters.audience_activation_methods` on [`get_products`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/get_products) to find inventory compatible with your data pipeline. [External source references](#external-audience-sources-experimental) — pointing this task at a shared dataset or vendor-distributed segment instead of pushing members — are available experimentally on sellers that declare the corresponding activation patterns.

**Size limit**: Payloads are limited to 100,000 members per call across all audiences. For larger lists, chunk into sequential calls using `add` deltas.

**Concurrency**: Ensure that calls made to `sync_audience` are independent of eachother. They may be processed out-of-order. If you need sequential execution, wait for the callback to your configured webhook before making another call.

## Response shapes

Responses use discriminated unions — a response has exactly one of three shapes, never mixed:

**1. Synchronous success** — per-audience results:

* `audiences` — Results for each audience on the account, including audiences not in this request
* `sandbox` — Boolean indicating if this response is from sandbox mode (optional)

**2. Terminal failure** — no audiences processed:

* `errors` — Array of operation-level errors (auth failure, account not found, invalid request format)

**3. Submitted task envelope** — whole operation queued asynchronously (batch ingestion, governance-gated upload, clean-room flows where the seller cannot return per-audience results before the response is emitted):

* `status` — Always `"submitted"`
* `task_id` — Handle for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or receiving a webhook on completion
* `message` — Optional human-readable explanation of the queue state

The final per-audience `audiences` array lands on the task completion artifact, not on the submitted envelope. Per-audience asynchronous matching (one audience in `processing` while the rest of the sync resolves synchronously) belongs on the synchronous success branch with `status: "processing"` on that item, not on the submitted envelope. Matching latency on the per-audience [audience-status](#audience-status) enum is the common case; the submitted envelope is for the less-common operation-level async case.

**Each audience in success response includes:**

| Field | Description |
| - | - |
| `audience_id` | Echoed from request (buyer's identifier) |
| `platform_id` | ID assigned to this audience by the seller's ad platform (optional; new in AdCP 3.3) |
| `seller_id` | Deprecated compatibility alias for `platform_id` |
| `action` | `created`, `updated`, `unchanged`, `deleted`, or `failed` |
| `status` | `processing`, `ready`, or `too_small`. Present when action is `created`, `updated`, or `unchanged`; absent when action is `deleted` or `failed`. |
| `uploaded_count` | Members submitted in this sync operation (delta, not cumulative). 0 for discovery-only calls. |
| `total_uploaded_count` | Cumulative members uploaded across all syncs. Compare with `matched_count` to calculate match rate. |
| `matched_count` | Total members matched to platform users across all syncs (cumulative). Populated when `status: "ready"` and the seller can report it. May be absent: buyers MUST NOT read absence as zero. See [Audience size](#audience-size). |
| `effective_match_rate` | Deduplicated match rate across all identifier types (0–1). A single number for reach estimation. Populated when `status: "ready"` and the seller can report it. May be absent: buyers MUST NOT read absence as zero. |
| `match_rate_basis` | `platform_reported` or `derived`. Says where `effective_match_rate` came from. See [Audience size](#audience-size). |
| `audience_size` | People matched on the platform, as a range: `lower_bound`, `upper_bound`, `precision` (`exact`, `approximate`, or `bucketed`), and optional `as_of`. See [Audience size](#audience-size). |
| `targetable_size` | People the platform can serve to now, in the same range shape as `audience_size`. See [Audience size](#audience-size). |
| `match_breakdown` | Per-identifier-type match results. Shows which ID types resolve and at what rate. See [match breakdown](#match-breakdown). |
| `last_synced_at` | ISO 8601 timestamp of the most recent sync. Omitted if the seller does not track this. |
| `minimum_size` | Minimum matched audience size for targeting on this platform. Populated when `status: "too_small"`. |
| `errors` | Per-audience errors (only when `action: "failed"`) |

## Platform identifier migration (AdCP 3.3)

`platform_id` identifies the audience in the seller's ad platform. Continue using `audience_id` in AdCP sync requests and targeting; the platform ID is for cross-referencing the platform's object.

When reporting a platform identifier in 3.3 and later 3.x responses, sellers MUST emit both `platform_id` and the deprecated `seller_id` alias with identical values, including in discovery-only responses. Both fields remain optional when no platform identifier is available. Buyers MUST prefer `platform_id` and fall back to `seller_id` only when `platform_id` is absent. A response containing only the legacy alias remains schema-valid for compatibility with older sellers.

The alias remains functional throughout 3.x. Removal is no earlier than AdCP 4.0 and follows the [six-month deprecation policy](/dist/docs/3.3.0-beta.0/reference/versioning#deprecation-policy). At this location, `seller_id` continues to mean the audience's platform ID; it MUST NOT be repurposed to identify the seller.

## Audience size

Platforms report audience size in different ways. Some give an exact count, some an approximation or interval for member privacy, and some only a coarse bucket. A single `matched_count` would force the seller to invent a point value, so a seller can report the size as a range instead.

`audience_size` is the number of people the audience matched to on the platform. `targetable_size` is the number of people the platform can serve to now, which can be smaller when only part of the matched audience is eligible to be served. Both use the same object, defined once as [`audience-size.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/core/audience-size.json):

| Field | Required | Description |
| - | - | - |
| `lower_bound` | Yes | Lowest number of people the size can be. Integer, at least 0. |
| `upper_bound` | Yes | Highest number of people the size can be. Integer, at least 0, and MUST be greater than or equal to `lower_bound`. |
| `precision` | Yes | `exact`, `approximate`, or `bucketed`. |
| `as_of` | No | ISO 8601 timestamp of when the platform computed this size. |

```json theme={null}
{
  "audience_id": "crm_customers",
  "action": "updated",
  "status": "ready",
  "audience_size": {
    "lower_bound": 14000000,
    "upper_bound": 16500000,
    "precision": "approximate",
    "as_of": "2026-10-08T12:00:00Z"
  },
  "targetable_size": {
    "lower_bound": 9000000,
    "upper_bound": 11000000,
    "precision": "bucketed",
    "as_of": "2026-10-08T12:00:00Z"
  },
  "effective_match_rate": 0.42,
  "match_rate_basis": "platform_reported"
}
```

Key semantics:

* **`precision` says how to read the bounds.** With `exact`, `lower_bound` equals `upper_bound`. With `approximate`, the true size is expected to fall within the bounds. With `bucketed`, the bounds are the edges of a coarse bucket and say nothing finer than which bucket the audience falls in.
* **Matched is not targetable.** `targetable_size` can be smaller than `audience_size`. Plan reach against `targetable_size` when a seller reports it.
* **`matched_count` and `effective_match_rate` can be absent.** A seller whose platform reports a size but no matched count, as with some social platforms, omits `matched_count` and `effective_match_rate`, and reports `audience_size`. Buyers MUST NOT read an absent count or rate as zero.
* **`match_rate_basis` qualifies the rate.** `platform_reported` means the platform supplied `effective_match_rate`. `derived` means the seller computed it, for example from approximate counts, so it is itself approximate.
* **Nothing below the seller's minimum is disclosed.** A size below the seller's minimum stays governed by the `too_small` status and `minimum_size`. A seller MUST omit `audience_size` and `targetable_size` when `status` is `too_small`.
* **All fields are optional.** Sellers that do not report a range keep returning `matched_count` and `effective_match_rate` exactly as before.

## Match breakdown

When a seller supports per-identifier-type reporting, the response includes `match_breakdown` — an array showing which identity types are resolving and at what rate. This helps buyers decide which identifiers to prioritize in future uploads.

```json theme={null}
{
  "audience_id": "existing_customers",
  "action": "updated",
  "status": "ready",
  "uploaded_count": 5000,
  "total_uploaded_count": 25000,
  "matched_count": 18750,
  "effective_match_rate": 0.75,
  "match_breakdown": [
    { "id_type": "hashed_email", "submitted": 25000, "matched": 17500, "match_rate": 0.70 },
    { "id_type": "hashed_phone", "submitted": 15000, "matched": 12000, "match_rate": 0.80 },
    { "id_type": "rampid", "submitted": 8000, "matched": 7200, "match_rate": 0.90 }
  ]
}
```

Key semantics:

* **`submitted` and `matched` are cumulative** across all syncs, matching `total_uploaded_count` semantics (not `uploaded_count`).
* **`effective_match_rate` is deduplicated** — a member matched via both email and phone counts once. It will be less than or equal to the sum of per-type match rates.
* **`match_rate` is server-authoritative** — consumers should prefer this value over computing their own from submitted/matched.
* **`id_type` values** combine hashed PII types (`hashed_email`, `hashed_phone`) with universal ID types (`rampid`, `uid2`, `id5`, `euid`, `pairid`, `maid`).

Sellers that only support aggregate match counts omit `match_breakdown` entirely.

## Common Scenarios

### Discovery Only

Check status of all existing audiences without making changes. The response includes all audiences on the account — filter by `audience_id` to find the one you care about:

```javascript JavaScript theme={null}
import { testAgent } from "@adcp/sdk/testing";
import { SyncAudiencesResponseSchema } from "@adcp/sdk";

const result = await testAgent.syncAudiences({
  account: { account_id: "acct_12345" },
});

if (!result.success) {
  throw new Error(`Request failed: ${result.error}`);
}

const validated = SyncAudiencesResponseSchema.parse(result.data);

if ("errors" in validated && validated.errors) {
  throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`);
}

if ("audiences" in validated) {
  for (const audience of validated.audiences) {
    console.log(`${audience.audience_id}: ${audience.status ?? "n/a"}`);
  }
}
```

```python Python theme={null}
import asyncio
from adcp.testing import test_agent

async def main():
    result = await test_agent.simple.sync_audiences(account={'account_id': 'acct_12345'})

    if hasattr(result, 'errors') and result.errors:
        raise Exception(f"Operation failed: {result.errors}")

    for audience in result.audiences:
        status = getattr(audience, 'status', 'n/a')
        print(f"{audience.audience_id}: {status}")

asyncio.run(main())
```

### Suppression List

Upload a list of existing customers to suppress from acquisition campaigns:

```javascript JavaScript theme={null}
import { testAgent } from "@adcp/sdk/testing";
import { SyncAudiencesResponseSchema } from "@adcp/sdk";
import { createHash } from "crypto";

const hashEmail = (email) =>
  createHash("sha256").update(email.toLowerCase().trim()).digest("hex");

// Hashed customer emails from CRM export
const existingCustomers = [
  { hashed_email: hashEmail("customer1@example.com") },
  { hashed_email: hashEmail("customer2@example.com") },
];

const result = await testAgent.syncAudiences({
  account: { account_id: "acct_12345" },
  audiences: [
    {
      audience_id: "existing_customers",
      name: "Existing customers — suppression",
      add: existingCustomers,
    },
  ],
});

if (!result.success) {
  throw new Error(`Request failed: ${result.error}`);
}

const validated = SyncAudiencesResponseSchema.parse(result.data);

if ("errors" in validated && validated.errors) {
  throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`);
}

if ("audiences" in validated) {
  const audience = validated.audiences[0];
  console.log(`Status: ${audience.status}`);
  // When ready, reference audience_id in create_media_buy targeting_overlay.audience_exclude
}
```

```python Python theme={null}
import asyncio
import hashlib
from adcp.testing import test_agent

def hash_email(email: str) -> str:
    return hashlib.sha256(email.lower().strip().encode()).hexdigest()

async def main():
    existing_customers = [
        {'hashed_email': hash_email('customer1@example.com')},
        {'hashed_email': hash_email('customer2@example.com')},
    ]

    result = await test_agent.simple.sync_audiences(
        account={'account_id': 'acct_12345'},
        audiences=[{
            'audience_id': 'existing_customers',
            'name': 'Existing customers — suppression',
            'add': existing_customers
        }]
    )

    if hasattr(result, 'errors') and result.errors:
        raise Exception(f"Operation failed: {result.errors}")

    audience = result.audiences[0]
    print(f"Status: {audience.status}")
    # When ready, reference audience_id in create_media_buy targeting_overlay.audience_exclude

asyncio.run(main())
```

### Removing Members

Update an audience incrementally — add new members and remove ones that no longer qualify:

```javascript JavaScript theme={null}
import { testAgent } from "@adcp/sdk/testing";
import { SyncAudiencesResponseSchema } from "@adcp/sdk";
import { createHash } from "crypto";

const hashEmail = (email) =>
  createHash("sha256").update(email.toLowerCase().trim()).digest("hex");

const result = await testAgent.syncAudiences({
  account: { account_id: "acct_12345" },
  audiences: [
    {
      audience_id: "lapsed_subscribers",
      name: "Lapsed subscribers",
      add: [{ hashed_email: hashEmail("newlapse@example.com") }],
      remove: [{ hashed_email: hashEmail("reactivated@example.com") }],
    },
  ],
});

if (!result.success) {
  throw new Error(`Request failed: ${result.error}`);
}

const validated = SyncAudiencesResponseSchema.parse(result.data);

if ("errors" in validated && validated.errors) {
  throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`);
}

if ("audiences" in validated) {
  for (const audience of validated.audiences) {
    console.log(`${audience.audience_id}: ${audience.action}`);
  }
}
```

```python Python theme={null}
import asyncio
import hashlib
from adcp.testing import test_agent

def hash_email(email: str) -> str:
    return hashlib.sha256(email.lower().strip().encode()).hexdigest()

async def main():
    result = await test_agent.simple.sync_audiences(
        account={'account_id': 'acct_12345'},
        audiences=[{
            'audience_id': 'lapsed_subscribers',
            'name': 'Lapsed subscribers',
            'add': [{'hashed_email': hash_email('newlapse@example.com')}],
            'remove': [{'hashed_email': hash_email('reactivated@example.com')}]
        }]
    )

    if hasattr(result, 'errors') and result.errors:
        raise Exception(f"Operation failed: {result.errors}")

    for audience in result.audiences:
        print(f"{audience.audience_id}: {audience.action}")

asyncio.run(main())
```

### Deleting an Audience

Remove a specific audience from the account without affecting others. Set `delete: true` on the audience object:

```javascript JavaScript theme={null}
import { testAgent } from "@adcp/sdk/testing";
import { SyncAudiencesResponseSchema } from "@adcp/sdk";

const result = await testAgent.syncAudiences({
  account: { account_id: "acct_12345" },
  audiences: [
    { audience_id: "old_campaign_list", delete: true },
  ],
});

if (!result.success) {
  throw new Error(`Request failed: ${result.error}`);
}

const validated = SyncAudiencesResponseSchema.parse(result.data);

if ("audiences" in validated) {
  const audience = validated.audiences.find(a => a.audience_id === "old_campaign_list");
  console.log(`${audience.audience_id}: ${audience.action}`); // "deleted"
}
```

```python Python theme={null}
import asyncio
from adcp.testing import test_agent

async def main():
    result = await test_agent.simple.sync_audiences(
        account={'account_id': 'acct_12345'},
        audiences=[{'audience_id': 'old_campaign_list', 'delete': True}]
    )

    if hasattr(result, 'errors') and result.errors:
        raise Exception(f"Operation failed: {result.errors}")

    audience = next(a for a in result.audiences if a.audience_id == 'old_campaign_list')
    print(f"{audience.audience_id}: {audience.action}")  # "deleted"

asyncio.run(main())
```

To delete multiple audiences in one call, include each with `delete: true`. To delete all buyer-managed audiences at once, use `delete_missing: true` with an empty `audiences` array — but be careful, this removes everything.

### Using Audiences in a Media Buy

Once an audience is `ready`, reference it by `audience_id` in [`create_media_buy`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy) targeting overlays. Audience IDs are scoped to the seller account — they cannot be used across sellers.

```json test=false theme={null}
{
  "brand": { "house_domain": "acme.com", "brand_id": "main" },
  "start_time": "asap",
  "end_time": "2026-03-31T23:59:59Z",
  "packages": [
    {
      "product_id": "prod_sponsored_content",
      "pricing_option_id": "cpm_standard",
      "budget": 10000,
      "targeting_overlay": {
        "audience_include": ["high_value_prospects"],
        "audience_exclude": ["existing_customers"]
      }
    }
  ]
}
```

## External Audience Sources (experimental)

> [!NOTE]
>
> **Experimental** (`media_buy.audience_activation`). External sources share the activation-declaration feature id: sellers implementing them MUST list `media_buy.audience_activation` in `experimental_features`, and the surface may change between 3.x releases with notice. See [Experimental status](/dist/docs/3.3.0-beta.0/reference/experimental-status).

Instead of pushing hashed members inline, an audience can carry a **source reference** — a pointer to buyer data the seller ingests over a declared [activation rail](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/media-products#audience-activation). The data never transits AdCP: the protocol moves the pointer, ingestion happens on the vendor's rails, and the resulting audience lands in the same lifecycle (status enum, match reporting, discovery, targeting overlays) as a pushed audience.

Two source kinds, mapping 1:1 onto the seller's declared activation patterns. Buyers MUST only send kinds whose pattern the seller declared (`dataset_query` → `dataset`, `platform_distribution` → `platform_segment`); undeclared kinds are rejected with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-unsupported-feature).

**Schema**: [`/schemas/3.3.0-beta.0/core/audience-source.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/core/audience-source.json)

### Dataset (grant-based)

The seller reads a table or share the buyer granted through the vendor's grantee-identified sharing mechanism (Snowflake Secure Data Sharing, Databricks Delta Sharing using D2D or OIDC token federation, BigQuery authorized views):

1. Read the seller's declared `consumer_identities` from its [`audience_activation`](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/media-products#audience-activation) declaration.
2. Grant access in the vendor's system (e.g. `ALTER SHARE audience_share ADD ACCOUNTS = SELLERORG.INGEST_US`). Out of band — AdCP never carries credentials. The grant MUST name a principal without conveying a secret through AdCP: Databricks D2D and open sharing with OIDC token federation qualify; the classic bearer-token recipient profile does not. A request carrying credential material is rejected with [`CREDENTIAL_IN_ARGS`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-credential-in-args).
3. Call `sync_audiences` with the source reference:

```json test=false theme={null}
{
  "audience_id": "high_value_customers",
  "name": "High-value customers",
  "audience_type": "crm",
  "source": {
    "kind": "dataset",
    "vendor": { "domain": "data-cloud.example" },
    "locator": "PINNACLE.AUDIENCES.HIGH_VALUE_V3",
    "access_expires_at": "2026-12-01T00:00:00Z"
  }
}
```

> [!NOTE]
>
> For Databricks, keep the two sharing identifiers distinct. The seller advertises its non-secret recipient identity in `consumer_identities[]` as `<cloud>:<region>:<uuid>`. The buyer passes what to read in `source.locator` using the Databricks-path convention `share://<provider-sharing-identifier>/<share-name>/<schema>.<object>`. For example: `share://aws:us-east-1:<provider-uuid>/pinnacle_audiences/suppression.high_value_v3`. The seller resolves the provider identity and share name against the grant before reading the object.

4. The seller reads, matches, and the audience moves `processing` → `ready` / `too_small` exactly as for pushed audiences. A missing or revoked grant is a retryable per-audience failure (`action: "failed"` with [`SOURCE_ACCESS_FAILED`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-source-access-failed)) — grant and retry.

**Column contract.** The shared object MUST expose the canonical AdCP identifier columns mirroring the [audience member](#audience-member) shape: `external_id` (required; alias any stable per-row key, e.g. a clean-room output's resolved key), plus `hashed_email`, `hashed_phone`, and `uid_type`/`uid_value` rows as available. Same normalization and hashing rules as the inline path — these are *the* canonical pre-hash forms; receivers needing other forms cannot re-derive them from a one-way hash, so a table storing a non-canonical form silently loses match rate. The seller does any joining against its own tables internally; the buyer never conforms to seller-internal schemas.

For a multi-publisher Databricks share, the buyer or broker can publish one canonical view and use recipient properties for row-level isolation so each publisher sees only its entitled rows:

```sql theme={null}
CREATE FUNCTION audience_rls(pub STRING)
  RETURN pub = CURRENT_RECIPIENT('publisher_id');
ALTER TABLE broker.audiences.master SET ROW FILTER audience_rls ON (publisher_id);
```

**Refresh.** A standing grant is a live pipe: while access lasts, the seller MAY re-read the source to keep membership as current as the share's replication lag (direct same-region shares are zero-copy live; cross-region replication or listing auto-fulfillment bounds freshness by the replication schedule). Each read updates `last_synced_at`. Buyers wanting snapshot semantics revoke the grant after `ready`.

### Platform segment (destination-initiated)

The vendor's rails deliver the segment to the seller's declared `platform_distribution` destination:

1. Read the seller's `destination_ref` from its activation declaration (or account setup).
2. Initiate distribution in the vendor's system.
3. Call `sync_audiences` with `source: { "kind": "platform_segment", "vendor": { "domain": "activation-hub.example" }, "segment_ref": "seg_88213" }` to bind the arriving segment to your `audience_id`.

`segment_ref` is **the vendor's identifier as issued to you** — the ID you can observe in the vendor's console or API. The seller owns the mapping to whatever identifier its ingest minted; it configured the destination and is the only party that sees both sides. Binding is order-independent: if the bind arrives before the segment, the audience sits in `processing`. Initial vendor distribution is days-scale with multi-day refresh cadences — sellers MAY expire an unfulfilled bind after their declared `bind_expiry_days` (per-audience `action: "failed"`), and declared windows shorter than the vendor's documented distribution latency are non-conformant.

### Lifecycle rules

* **Transport is fixed at creation.** An audience is either buyer-pushed or externally sourced for its lifetime. Cross-transport upserts (member deltas against a sourced audience, or `source` against a pushed one) are rejected with [`CONFLICT`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-conflict) (`error.field: "audience_id"`); convert by delete-and-recreate.
* **Loss of source access MUST NOT change audience status.** The seller cannot reliably distinguish a revoked grant from an expired one from a transient vendor outage — all three observables are a failed read — so the only implementable rule keys on read failure: last matched state persists and remains targetable, `last_synced_at` freezes, and failed re-reads MUST NOT emit `suspended` (that value stays reserved for the consent/policy causes on the status enum, which remain orthogonal and still apply). Source health is reported separately via `source.access_status` (`active` | `unavailable`).
* **Access expiry is not deletion.** `access_expires_at` and revocation bound the access window, not what the seller matched during it — exactly as un-pushing is impossible on the inline path. Retention, deletion, and data-subject requests remain governed by the buyer–seller data processing agreement; spec text MUST NOT present expiry as a privacy feature. Teardown stays an explicit buyer action (`delete: true`).
* **Counts are as-of `last_synced_at`.** Required on sourced audiences whenever counts are populated (`ready` / `too_small`): two reads can differ because membership changed, match rate changed, or the seller re-read the source, and the timestamp is the buyer's only anchor. `total_uploaded_count` is cumulative-distinct and monotonic — not a size metric.
* **Single pane.** Externally sourced audiences appear in discovery-only calls identically to pushed ones — same status enum, same match reporting, same targetability — with the `source` echoed (kind, vendor, reference; never credentials) plus `columns_read` (which canonical columns the seller actually read, so zero matches on an offered identifier type is distinguishable from a column the seller never read) and `access_status`.

## Audience Status

Platform matching is asynchronous. The `status` field reflects the current state:

| Status | Meaning |
| - | - |
| `processing` | Platform is matching uploaded members against its user base. Poll again later — do not create campaigns yet. |
| `ready` | Audience is available for targeting. `matched_count` and `audience_size` are populated when the seller can report them. |
| `too_small` | Matched audience is below the platform's minimum size. `minimum_size` in the response tells you the threshold. Add more members and re-sync. |

`status` is present when `action` is `created`, `updated`, or `unchanged`. It is absent when `action` is `deleted` or `failed`.

Sellers MUST emit `too_small` whenever `matched_count < minimum_size`. Returning `ready` with a `matched_count` below the platform minimum is non-compliant — buyers rely on the status value as a programmatic signal that targeting will fail, not on post-hoc interpretation of the count.

**Webhook (recommended)**: Configure `push_notification_config` at the protocol level before uploading. The task stays active while the seller's platform matches members. When matching completes, the task completes and the webhook fires with the final result — `status: "ready"` or `status: "too_small"`. Check [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.0/protocol/get_adcp_capabilities) → `audience_targeting.matching_latency_hours` to set realistic expectations (typically 1–48 hours).

**Polling fallback**: If not using webhooks, poll with discovery-only calls (omit `audiences`) no more frequently than every 15 minutes. A2A profile callers use [`get_task_status`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/protocol/get-task-status-request.json) with the `task_id`; MCP callers use the advertised AdCP polling task. The task will be `submitted` while matching is in progress and `completed` when the audience is ready or too small.

**Agent workflow**: Upload with `push_notification_config` set. Externalize the `audience_id` and `account_id` before the session ends. When the webhook fires with `status: "ready"`, resume and proceed to [`create_media_buy`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy).

## Async patterns

Two distinct async patterns — match the right one to the seller's behavior:

**Per-audience async matching** (common): the sync operation itself resolves synchronously and returns per-audience results immediately. Audiences whose matching is still running come back on the synchronous success response with `status: "processing"`. The buyer reconciles terminal state (`ready` / `too_small`) via subsequent discovery-only calls or a webhook. This is the case covered by the [Audience Status](#audience-status) enum above.

**Operation-level async** (less common): the whole sync is queued — the seller cannot return any per-audience results before responding, because ingestion is batched, governance review gates the upload, or an upstream clean-room flow must settle before matching can start. The response is a submitted envelope:

* Top-level `status: "submitted"` with `task_id`
* `message` — optional human-readable explanation
* No `audiences` array on this envelope

Invoke [`get_task_status`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or wait for the webhook. The completion artifact carries the `audiences` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task.

**See:** [Webhooks](/dist/docs/3.3.0-beta.0/building/by-layer/L3/webhooks) for webhook configuration.

## Hashing Requirements

Hash emails and phone numbers with SHA-256 before sending, normalizing first. `uids[]` values are not hashed, except a `hashed_email` entry, which carries the same SHA-256 form as the top-level field; send the forms noted below:

| Identifier | Normalization | Example |
| - | - | - |
| Email | Lowercase, trim whitespace | `alice@example.com` → hash |
| Phone | E.164 format | `+12065551234` → hash |
| MAID | Lowercase, hyphenated UUID (senders SHOULD; receivers SHOULD normalize case and hyphenation before matching) | `6d3c2f1a-8b4e-4c7d-9a1f-2e5b7c9d0f13` |
| UID2 / EUID | Raw UID2/EUID, never the encrypted token. Case-sensitive; do not case-fold or re-encode | As issued |

```javascript test=false theme={null}
import { createHash } from "crypto";

const hashEmail = (email) =>
  createHash("sha256").update(email.toLowerCase().trim()).digest("hex");

const hashPhone = (e164Phone) =>
  createHash("sha256").update(e164Phone).digest("hex");
```

## Privacy Considerations

The schema never carries cleartext email or phone — buyers MUST hash before transport. The seller matches by independently hashing its own user data with the same algorithm.

**Hashed identifiers are pseudonymous PII, not anonymous.** Unsalted SHA-256 of an email or phone number is recoverable via precomputed dictionaries of the email and E.164 namespaces, so `hashed_email` and `hashed_phone` MUST be treated as PII for retention, consent, access control, and data-subject-request purposes. Do not describe them as "privacy-preserving" in operator documentation or DPAs. See [Privacy Considerations](/dist/docs/3.3.0-beta.0/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous).

**Buyer obligations**: The buyer is responsible for having a lawful basis to process and share audience data, regardless of jurisdiction. Include `consent_basis` on each audience to communicate the GDPR lawful basis to sellers operating in regulated markets — some sellers require this field for EU audiences.

**Data handling**: Once uploaded, data processing and retention are governed by your agreement with the seller. Review the seller's data processing terms before uploading audience data.

## Error Handling

| Error Code | Description | Resolution |
| - | - | - |
| [`ACCOUNT_NOT_FOUND`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-account-not-found) | Account does not exist | Verify `account_id` |
| [`REFERENCE_NOT_FOUND`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-reference-not-found) | Audience to remove from doesn't exist or is not accessible (`error.field` = `audience_id`) | Check `audience_id` or omit `remove` |
| [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-invalid-request) | Identifier format or payload size is invalid | Verify SHA-256 hex encoding and keep the payload within the documented limit |
| [`RATE_LIMITED`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-rate-limited) | Too many sync requests | Retry with exponential backoff; poll no more than every 15 minutes |
| [`SOURCE_ACCESS_FAILED`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-source-access-failed) | Seller could not read an external audience source (per-audience, experimental) | `error.field` `source.locator`/`source.segment_ref`: fix the reference; `source`: grant the seller's declared consumer identity and retry; no `error.field`: vendor outage — retry with backoff |
| [`CONFLICT`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-conflict) | Cross-transport upsert: member deltas against a sourced audience or `source` against a pushed one (`error.field` = `audience_id`) | Transport is fixed at creation — delete and recreate to convert |
| [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-unsupported-feature) | `source` kind whose activation pattern the seller has not declared | Check the seller's `audience_activation` declarations before sending source references |

## Next Steps

* [Targeting](/dist/docs/3.3.0-beta.0/media-buy/advanced-topics/targeting) — Reference audiences in `targeting_overlay.audience_include` and `audience_exclude`
* [create\_media\_buy](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy) — Apply audience targeting to packages
* [Conversion Tracking](/dist/docs/3.3.0-beta.0/media-buy/conversion-tracking/) — Track outcomes from audience-targeted campaigns


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