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

# Property Lists

> Curate include and exclude inventory lists across web, mobile, and CTV for your advertisers and propagate them across active media buys

## Overview

A **property list** is a named, advertiser-scoped collection of typed
identifiers (websites, mobile apps, CTV apps) used to shape where a brand's
media runs. Lists carry a `purpose` of either `include` (only buy on these
properties) or `exclude` (never buy on these properties). Once created, lists
are linked to the brand's targeting profile and enforced when you buy:
include lists can travel to supporting sales agents during discovery or as
buy-time targeting. Apostra also filters conflicting inventory at selection
time.

Property lists are a **buyer-curated** concept. They are owned by your
advertiser and validated against the AAO property registry. Supporting sales
agents receive list references that they resolve on demand. Apostra enforces
exclusions on the buyer's side as a backstop.

<CardGroup cols={2}>
  <Card title="Include lists" icon="check">
    Restrict targeting to a hand-picked allow-list (e.g., a curated PMP of
    premium publishers).
  </Card>

  <Card title="Exclude lists" icon="ban">
    Block specific domains across all of an advertiser's campaigns (e.g., a
    brand-safety blocklist).
  </Card>
</CardGroup>

## How lists are enforced

Enforcement happens at **selection time**: when products are discovered and
when a campaign is executed. Delivery-grain enforcement at the seller
(dropping individual impressions inside an already-running buy) is not yet
available.

* **Include lists** are sent as `filters.property_list` during discovery when
  a sales agent declares either
  `media_buy.features.property_list_filtering: true` or
  `media_buy.execution.targeting.property_list`. The feature declaration
  authorizes discovery filtering only. A `targeting_overlay.property_list`
  reference is sent on media-buy packages only when the seller declares the
  execution-targeting dimension. Sellers without the relevant declaration do
  not receive the reference.
* **Exclude lists** are not sent in `get_products`: AdCP discovery filters do
  not define `property_list_exclude`. They remain enforced on the buyer side
  during discovery and are sent as `targeting_overlay.property_list_exclude`
  on media-buy packages only when a sales agent declares
  `media_buy.execution.targeting.property_list_exclude` support.
  When an advertiser links multiple exclusion catalogs, the seller receives
  one reference that resolves to their combined, deduplicated identifiers.
  Apostra also enforces the list on the buyer's side: products whose stored
  property identifiers match the advertiser's exclusion list are filtered out
  of product discovery results, and execution rejects conflicting selections.
  Discovery reports the number removed in
  `excludedProductCount`, per-storefront counts in
  `excludedProductCountsByStorefront`, and bounded product details in
  `excludedProductsByStorefront`; each detail contains the product ID, name,
  and matching domains plus typed identifiers. Canonical `get_products` reports the same total as
  `ext.interchange.excluded_product_count` and each seller row carries
  `excluded_product_count` plus bounded `excluded_products`. The domains are
  the intersection of that seller product and the buyer's own list, not the
  buyer's complete list. When an exclusion empties a completed result, the
  response guidance says how many products from how many sellers were removed
  because they included excluded properties instead of calling the result a
  lack of matching inventory. A legacy candidate without seller identity is
  included in the total but not assigned to a seller row, so the guidance
  reports the removed-product count without inventing a seller count.
  Executing a campaign fails with a clear error naming the conflicting products when a selected product's
  publisher is on the list. Remove those products from the buy, or remove the
  exclusion, to proceed. Matching uses the typed identifiers the current
  property-membership model stores: `domain`, `subdomain`, `ios_bundle`,
  `android_package`, `apple_tv_bundle`, `bundle_id`, `apple_app_store_id`,
  `google_play_id`, `roku_store_id`, `roku_channel_id`, `fire_tv_asin`,
  `fire_tv_app_id`, `amazon_app_store_id`, `samsung_app_id`, `lg_channel_id`,
  and `vizio_app_id`. Identifier values are exact after normalisation: domains,
  iOS bundles, and Apple TV bundles are lowercased; Android packages, generic
  bundles, store IDs, and channel IDs keep their case. Products can declare
  every AdCP identifier type, but DOOH venue and podcast list entries are not
  stored by this property-list model yet, so they do not match an exclusion
  list at selection time.
* **Products sold through an Apostra storefront** take the declaration of the
  sales agent behind the storefront, not the storefront's own. The storefront
  passes both references to that sales agent unchanged. Apostra reads the
  agent's declaration from the AdCP capabilities it last refreshed for it, so a
  seller declares `property_list` or `property_list_exclude` in its own sales
  agent's capabilities. Managed sales agents and platform adapters publish no
  such declaration, so they don't receive references.
* **A list reference you set on a package yourself** (`property_list`,
  `property_list_exclude`, `collection_list` or `collection_list_exclude` in a
  package's `targetingOverlay`) is sent only to a seller that declares that
  exact field under `media_buy.execution.targeting`. A collection-list
  reference also needs the product to allow collection targeting
  (`collection_targeting_allowed: true`); AdCP treats a product without it as
  a bundle. If the seller does not declare the field, executing the media buy,
  or updating a media buy the seller already holds, fails with a validation
  error that names the package, the field and the seller, and nothing is
  sent. A seller that does not declare a field is not
  obliged to apply the list, so sending it anyway could let the buy run
  without it. Remove the reference from the package, or choose products whose
  seller declares the field. If Apostra cannot read the seller's declaration,
  the request fails with a retryable error instead.

## Concept

Each list stores a set of typed identifiers that are normalized, deduped, and
resolved through two systems:

1. **AAO registry** — the cross-publisher Ad Context registry used to confirm a
   property is a real, identified entry.
2. **Local property catalog** — Apostra's mapping of identifiers to targetable
   `Property` records used by sales agents.

Submitted identifiers land in one of three buckets, surfaced in every
create/update response as a `resolutionSummary`:

| Bucket | Meaning | Targets? |
| - | - | - |
| `resolvedCount` | Mapped to a local `Property` record | Yes |
| `registeredCount` | Known to AAO but no local property yet | Not yet — will become targetable as catalog catches up |
| `unresolvedCount` | Not found anywhere | No — silently skipped |

A single list can hold up to **100,000 identifiers** per request. The service
chunks large inputs server-side against the AAO registry (which itself caps at
10,000 domains per call) and returns a single resource with the full
resolution summary.

## Identifier types

Property lists accept AdCP-aligned typed identifiers. Pass a typed array via
`identifiers: [{type, value}]`, or use the `domains: string[]` shorthand
when every entry is a website domain.

| Type | Resolves via | Example value |
| - | - | - |
| `domain` | `Domain.domain` (`SITE`) | `example-times.com` |
| `subdomain` | `Domain.domain` (`SITE`) | `news.example.com` |
| `ios_bundle` | `App.bundle` (Apple App Store) | `com.facebook.katana` |
| `android_package` | `App.bundle` (Google Play) | `com.facebook.katana` |
| `apple_tv_bundle` | `App.bundle` (Apple App Store) | `com.netflix.Netflix` |
| `bundle_id` (generic fallback) | `App.bundle` (any store) | `com.example.app` |
| `apple_app_store_id` | `Domain.domain` (`APPLE_APP_STORE`) | `284882215` |
| `google_play_id` | `Domain.domain` (`GOOGLE_PLAY_STORE`) | `com.example.app` |
| `roku_store_id` | `Domain.domain` (`ROKU`) | `12` |
| `fire_tv_asin` | `Domain.domain` (`AMAZON`) | `B00X4WHP5E` |
| `samsung_app_id` | `Domain.domain` (`SAMSUNG`) | `G19173000091` |

A single mobile app can appear in a property list under multiple identifier
types (e.g. `ios_bundle` AND `apple_app_store_id`); each resolves independently
against the AAO registry / local catalog.

<Note>
  `domains: ["example-times.com"]` is exactly equivalent to
  `identifiers: [{ "type": "domain", "value": "example-times.com" }]`. You can pass
  both fields in the same request — they're concatenated and deduplicated.
</Note>

<Note>
  **Read-side normalization.** `apple_tv_bundle` and `ios_bundle` share the
  same backing column (`App.bundle` with `appStore=APPLE_APP_STORE`), so an
  `apple_tv_bundle` write reads back as `ios_bundle` on subsequent `GET`/`list`
  responses. The generic `bundle_id` fallback likewise normalizes to the
  resolved app row's store-typed form (`ios_bundle` or `android_package`).
  Submit the type that best matches the AdCP property registry; expect the
  response to carry the canonical store-typed form.
</Note>

## Use with a V3 MCP agent

Buyer agents use one V3 write call for a property list's lifecycle:
`save_property_list`. Create a list with its advertiser, name, purpose, and
identifiers; update its name or identifier set with its returned ID; or archive
it with `isArchived: true`. Archiving is currently one-way.

To validate identifiers without saving a list, use the same call with
`check: true`, then pass its returned report ID to
`get(kind: "property_list", include: ["report"], reportId: "...")`; this
does not require a saved-list ID. `get` returns one bounded identifier page at
a time. Use its `identifierPage.nextOffset` with `identifierOffset` to continue,
and set `identifierCategory` to `unresolved` or `registered` when you need a
resolution bucket. Each page item is an AdCP identifier (`type`, `value`). A
value longer than 512 bytes is cut and flagged with `value_truncated: true`; a
cut value no longer matches a property, so do not copy it into another request.
`search` returns compact list summaries rather than identifier arrays. Use
`search(kind: "property_list", filter: { advertiserId: "123" })` to find an
advertiser's lists and `get` for a single list. The advertiser ID is required
because property lists are advertiser-scoped.

Every list belongs to the advertiser it was created for. By default a new
list becomes the advertiser's all-campaigns list for its purpose: it replaces
the previous one (the response names it in `replacedPropertyListIds`; the
replaced list stays saved but applies to no campaign), applies to every
campaign, and is pushed to active media buys. Create it with `appliesToAllCampaigns: false` to save it for
the advertiser without applying it to any campaign; the advertiser's lists and active
media buys are left unchanged. The choice is fixed at creation, and `get` and
`search` return it as `appliesToAllCampaigns`. If the advertiser's brand has no list
slot for the list's channels, a default create cannot apply the list anywhere:
it is saved for campaign use only and returned with
`appliesToAllCampaigns: false`.

## Campaign lists

Every campaign inherits the advertiser's lists. A campaign can also add one
include list and one exclude list of its own, stored on the campaign:

* `campaignPropertyListId` sets the campaign's own include list.
* `campaignPropertyListExcludeId` sets the campaign's own exclude list.

Both fields work the same way on `save_campaign` and on the v2 API's
`POST /campaigns/{campaignId}/property-lists`.

`propertyListId` is deprecated on both; use `campaignPropertyListId`. Until
2026-12-31 a non-null `propertyListId` keeps its old behaviour: it applies the
named include list, which must already be configured for the advertiser, to
the campaign's active media buys, combined with the campaign's own include
list, without storing it as the campaign's own list, and the response carries a `deprecationNotice` naming the replacement.
`propertyListId: null` is refused with a validation error, on `save_campaign`
and on the v2 endpoint alike. It used to remove property-list targeting from
the campaign's live buys, which would leave them running without the
advertiser's lists. To clear the campaign's own list, send
`campaignPropertyListId: null`; the advertiser's lists keep applying. After
2026-12-31 `propertyListId` is refused.

Each must be a list of the right purpose that belongs to the campaign's
advertiser. A list saved with `appliesToAllCampaigns: false` is the usual
choice, because it applies only where a campaign uses it. Set either field to
`null` to clear the campaign's own list; that never removes an advertiser list.
Both fields need an existing campaign (`campaignId`).

The lists combine the way you would expect:

* **Exclusions add up.** A property is excluded if the advertiser's exclude
  lists or the campaign's exclude list contain it. A campaign cannot remove an
  advertiser exclusion.
* **Inclusions narrow.** When both the advertiser and the campaign have an
  include list, a property must be on both.

A media-buy package carries one reference per field, so when more than one list
applies Apostra hosts one combined list and sends that reference. Sellers
resolve it through the same `GET /lists/:listId` endpoint as any other list.
Saving a campaign's lists pushes the lists in effect to the campaign's active
media buys, and every buy created afterwards carries them from its first
dispatch. Archiving a list clears it from any campaign that uses it.

Read the campaign with `get(kind: "campaign", include: ["propertyLists"])` (or
`GET /api/v2/buyer/campaigns/:campaignId/property-lists`). The response has:

| Field | Meaning |
| - | - |
| `campaignLists` | The campaign's own `campaignPropertyListId` and `campaignPropertyListExcludeId` (the same names `save_campaign` takes, so a read can be written back unchanged), each a list ID or `null` |
| `effective.include` / `effective.exclude` | The reference sent to sellers (`listId`) and the lists it combines (`listIds`, advertiser lists first), or `null` when none applies |
| `propertyLists` | The lists the campaign's media-buy packages currently reference, with the buys and packages that carry each one |

The same combination applies when a campaign is executed: products whose
properties are on the advertiser's or the campaign's exclude lists are
rejected. Product discovery filters by the advertiser's lists only; a
campaign's own lists take effect when its media buys are executed.

## Endpoints

All buyer endpoints below are mounted under `https://api.apostra.com/api/v2/buyer`.
The `GET /lists/:listId` resolution endpoint is mounted at the **app root**
(no `/api/v2/buyer` or `/api/v2/storefront` prefix) per ADCP convention.

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/api/v2/buyer/advertisers/:advertiserId/property-lists` | List lists for an advertiser |
| `POST` | `/api/v2/buyer/advertisers/:advertiserId/property-lists` | Create a list |
| `GET` | `/api/v2/buyer/advertisers/:advertiserId/property-lists/:listId` | Get one list |
| `PUT` | `/api/v2/buyer/advertisers/:advertiserId/property-lists/:listId` | Replace a list's domains and/or name |
| `DELETE` | `/api/v2/buyer/advertisers/:advertiserId/property-lists/:listId` | Archive a list |
| `POST` | `/api/v2/buyer/property-lists/check` | Validate a candidate domain set without creating a list |
| `GET` | `/lists/:listId` | High-cardinality public read used by sales agents (HMAC token auth, mounted at app root) |

All `propertyList`-returning responses use the `{ propertyList }` wrapper.
List endpoints return `{ propertyLists, total }`.

## How to use

### Create an include list

Domains-only shorthand:

```bash theme={null}
curl -X POST 'https://api.apostra.com/api/v2/buyer/advertisers/12345/property-lists' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Q1 UK Premium",
    "purpose": "include",
    "domains": ["example-times.com", "example-press.co.uk", "example-news.com"],
    "filters": {
      "channels_any": ["display", "olv"],
      "countries_all": ["GB"]
    }
  }'
```

Mixed web + mobile + CTV:

```bash theme={null}
curl -X POST 'https://api.apostra.com/api/v2/buyer/advertisers/12345/property-lists' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Cross-screen premium",
    "purpose": "include",
    "identifiers": [
      { "type": "domain", "value": "example-times.com" },
      { "type": "ios_bundle", "value": "com.exampletimes.ExampleTimes" },
      { "type": "android_package", "value": "com.exampletimes.android" },
      { "type": "apple_app_store_id", "value": "284862083" },
      { "type": "roku_store_id", "value": "12" }
    ]
  }'
```

Response:

```json theme={null}
{
  "propertyList": {
    "listId": "42",
    "name": "Cross-screen premium",
    "purpose": "include",
    "identifiers": [
      { "type": "domain", "value": "example-times.com" },
      { "type": "ios_bundle", "value": "com.exampletimes.ExampleTimes" },
      { "type": "android_package", "value": "com.exampletimes.android" }
    ],
    "unresolvedIdentifiers": [
      { "type": "apple_app_store_id", "value": "284862083" },
      { "type": "roku_store_id", "value": "12" }
    ],
    "registeredIdentifiers": [],
    "domains": ["example-times.com"],
    "unresolvedDomains": [],
    "registeredDomains": [],
    "propertyCount": 14,
    "resolutionSummary": {
      "totalRequested": 5,
      "resolvedCount": 3,
      "registeredCount": 0,
      "unresolvedCount": 2,
      "resolutionRate": 0.6
    },
    "createdAt": "2026-04-25T10:30:00.000Z",
    "updatedAt": "2026-04-25T10:30:00.000Z"
  }
}
```

**Response fields**

* `identifiers` — typed `{type, value}[]` actually resolved to local Property
  rows. **Persisted** as catalog membership.
* `unresolvedIdentifiers` — submitted identifiers with no matching local
  Property record. **Transient** — see persistence note below.
* `registeredIdentifiers` — identifiers found in the AAO registry but not yet
  locally targetable. Today only `domain` entries can land here. **Transient**.
* `domains` / `unresolvedDomains` / `registeredDomains` — convenience views of
  the above filtered to `type: "domain"`. App identifiers are **not** in these.
* `resolutionSummary` — counts and `resolutionRate` (0..1) over the
  deduplicated, normalized input.

<Note>
  `unresolvedIdentifiers` and `registeredIdentifiers` are **transient output of
  the resolution call**, not persisted state. They appear on create/update
  responses, then drop on subsequent `GET`s and on name-only `PUT`s. Only
  `identifiers` (the resolved set) is persisted on the list. Re-submit the
  identifier set on a `PUT` to re-surface them.
</Note>

### Update a list

A `PUT` replaces the full identifier set. Active media buys that reference
this list are then notified — see [Cascade behavior](#cascade-behavior).
You can pass `domains`, `identifiers`, or both:

```bash theme={null}
curl -X PUT 'https://api.apostra.com/api/v2/buyer/advertisers/12345/property-lists/42' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "identifiers": [
      { "type": "domain", "value": "example-times.com" },
      { "type": "ios_bundle", "value": "com.exampletimes.ExampleTimes" }
    ]
  }'
```

The update response includes `resolutionSummary` and `cascadeSummary` whenever
identifiers change. A name-only `PUT` returns neither.

### Validate before you commit

Use `POST /api/v2/buyer/property-lists/check` to lint a candidate identifier set
against curation rules without creating anything:

```bash theme={null}
curl -X POST 'https://api.apostra.com/api/v2/buyer/property-lists/check' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "identifiers": [
      { "type": "domain", "value": "example-times.com" },
      { "type": "domain", "value": "duplicate.com" },
      { "type": "domain", "value": "duplicate.com" },
      { "type": "ios_bundle", "value": "com.facebook.katana" }
    ]
  }'
```

The response groups entries into action buckets:

| Bucket | Meaning |
| - | - |
| `ok` | Clean — safe to include |
| `modify` | Canonicalized (e.g., `WWW.example.com` → `example.com`) |
| `remove` | Drop — duplicate or blocked |
| `assess` | Manual review recommended. **All non-domain identifiers (mobile/CTV) land here** — AAO does not currently check them. |

Each bucket entry carries an `identifier: { type, value }` field that mirrors
the input — use it to disambiguate types.

`reportId` (plus `reportIds[]` when domain input is chunked) is returned only
when at least one `domain` was submitted. Bundles-only and store-IDs-only
requests return no report IDs since no AAO call was made.

## Cascade behavior

When you `PUT` a list with `domains` or `identifiers`, every active media buy that
uses the list is notified via ADCP `update_media_buy` so the sales agent can
refresh its cached property set. This covers include and exclude lists alike,
whether the list is one of the advertiser's lists or a campaign's own list.
Each campaign's media buys receive the combined reference in effect for that
campaign (see [Property-list briefs for sellers](/v2/storefront/property-list-briefs)).
Each reference goes only to a sales agent that declares its targeting
dimension (`property_list` for include, `property_list_exclude` for exclude),
the same rule as a new buy. A buy whose sales agent doesn't declare it is
updated without that reference, and the skipped change is logged. The update
response surfaces this with a `cascadeSummary`:

```json theme={null}
{
  "cascadeSummary": {
    "totalMediaBuys": 4,
    "updatedCount": 3,
    "failedCount": 1
  }
}
```

<Warning>
  The cascade is **best-effort**. Cascade failures do **not** roll back the
  list update; the database is the source of truth and per-media-buy errors
  are logged for retry. A `PUT` that sends neither `domains` nor
  `identifiers` (a rename) does not cascade.
</Warning>

Per-media-buy fan-out is bounded (concurrency 5) so a large advertiser cannot
thunder a single sales agent.

## Resolution endpoint

Sales agents resolve a `PropertyListReference` by calling
`GET https://api.apostra.com/lists/:listId` with an HMAC bearer token
that Apostra mints when it embeds the reference in a request. This endpoint is
mounted at the **app root** (no `/api/v2/buyer` or `/api/v2/storefront` prefix) per ADCP convention. The
endpoint returns ADCP `GetPropertyListResponse` shape (not the standard
`{ data, error, meta }` envelope):

```json theme={null}
{
  "list": { "list_id": "42", "name": "Cross-screen premium" },
  "identifiers": [
    { "type": "domain", "value": "example-times.com" },
    { "type": "ios_bundle", "value": "com.exampletimes.ExampleTimes" },
    { "type": "android_package", "value": "com.exampletimes.android" }
  ],
  "resolved_at": "2026-04-25T10:30:00.000Z",
  "cache_valid_until": "2026-04-26T10:30:00.000Z"
}
```

Agents are expected to cache the response until `cache_valid_until` (24 h).

## Best practices

* **Always inspect `resolutionSummary`.** Any non-zero `unresolvedCount`
  indicates identifiers that will not target — surface them to the user before
  going live.
* **Submit multiple identifier types for the same app.** A mobile app is more
  reliably resolved when you include both its `ios_bundle` and
  `android_package` (and, where available, `apple_app_store_id` /
  `google_play_id`). Each is resolved independently against the AAO registry.
* **Prefer one large list over many small ones.** A 100k-identifier list is
  cheaper than 100 lists of 1k identifiers because each list creates its own
  `SmartPropertyList` link.
* **Use `filters.channels_any` to scope a list.** Without it, the list applies
  across all of the brand's targeting profiles. With it, you can keep
  display/OLV separate from CTV.
* **Treat updates as full replacements.** `PUT` replaces the entire identifier
  set; there is no incremental add/remove. Re-send the union you want stored.
* **Validate before bulk import.** Run `POST /api/v2/buyer/property-lists/check`
  on the raw input first, especially when assembling a list from spreadsheets
  or third-party feeds.
* **Avoid touching lists during high-traffic windows.** Cascading notifies
  every active media buy that uses the list; schedule large updates for
  off-peak times when possible.

## Limits

| Limit | Value |
| - | - |
| Identifiers per request (create / update / check) | 100,000 |
| Domains per AAO registry call (auto-chunked) | 10,000 |
| Cascade concurrency per update | 5 media buys at a time |
| List name length | 1–255 characters |
| Cache TTL on resolved list | 24 hours |

## Related

<CardGroup cols={2}>
  <Card title="Property-list briefs for sellers" href="/v2/storefront/property-list-briefs" icon="list-check">
    What a seller's agent receives and resolves when a buyer campaign carries a
    property list.
  </Card>

  <Card title="Publisher properties and coverage" href="/v2/storefront/inventory-sources/publisher-properties-coverage" icon="globe">
    How seller coverage appears in `get_products`.
  </Card>
</CardGroup>


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