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

# v3 Tool Catalog

> The tool families the account-resolved v3 MCP endpoint exposes. v3 is a preview contract: tools, nouns and fields can change.

> [!NOTE]
>
> `tools/list` is authoritative for the account kinds you can reach. A
> principal with both Buyer and Seller access receives their deduplicated union
> at connection time, while calls remain authorized against the account each
> call names with `accountId`, which every tool except `get_status`,
> `save_account` and `save_directed_campaign_subscription` requires. On those
> two writes `accountId` used to name the thing being written, so it stays
> optional until the old shape is removed; send it beside `id` or
> `providerAccountId`.
> See [Name the account on every call](https://docs.apostra.com/v3/name-the-account.md). Enrollment,
> permissions, feature rollout, and host support can narrow the catalog. This
> page explains the complete registered families and intended use.
>
> `save_lesson` is available in a Seller Account context. Name the Seller
> Account with `accountId`. `save_session` can
> appear when a compatible Session source is available.

## Navigation and shared tools

The [Identity and brands guide](https://docs.apostra.com/v2/setup/v3/identity-setup.md) covers current
identity and provenance, domain correction, public brand lookup, confirmation
and human proof follow-up in text-only clients. These operations use
`get_status`, `get`, `save_buyer_operator`, `save_advertiser` and `save_seller`.
Select `identityContract: "confirmed-v1"` for the preview and confirmation
workflow; existing save payloads keep their earlier contract.

`switch_account` is retired on v3 and is in no tool list. A client working from
a cached tool list can still call it for now; its result says it is retired and
to send `accountId` on every call instead. Call `get_status` with no `accountId`
to list the accounts you can reach, or with an `accountId` to read that
account's readiness and available tools.

| Tool | Accounts | Purpose |
| - | - | - |
| `get_status` | Every account | Read one account's readiness, blockers and next actions (with `accountId`), or list the accounts you can reach (without it). A Buyer account can add `include: ["setup"]` for the full checklist and a page of sellers, paged with `sellersCursor` |
| `switch_account` | Cached tool lists only | Retired: runs from a cached list and says to send `accountId` instead |
| `save_ask` | Every account | File or update support, product, supply, integration, or commercial asks |
| `save_notification_config` | Shared; V3 beta buyer agents can also manage their own event subscriptions | Save typed notification thresholds and preferences, or an agent-owned event subscription that uses one verified endpoint ID |
| `search` | Buyer, Seller, Organisation, Developer | Search supported kinds, documentation, or the AdCP specification; Organisation accounts list child accounts and members and search documentation, Developer accounts can search their owned agents and organization buyer agents, and Seller accounts can list the partner sales agents they can connect through |
| `get` | Buyer, Seller, Organisation, Developer | Read an object or singleton; Organization accounts read billing, child accounts and members, and Developer accounts can read their owned agents and organization buyer agents |
| `save_account` | Organisation; Buyer, Seller (qualified) | Create a child account (no `id`), rename one, or archive or restore it (`isArchived`). Each is recorded for website approval by an organization administrator, and `dryRun` previews it. A rename without `reason` and `idempotencyKey` is refused. `settings.company` saves at once. A direct organization administrator, or a Scope3 platform operator, also sees it from a Buyer or Seller account and sends `organizationId` to act for that organization. |
| `save_buyer_agent` | Buyer, Developer | Create, rename, reconcile advertiser grants, set the lifecycle state, or prepare a human credential handoff for one organization buyer agent. Without `id` it creates; with `id` send exactly one of `displayName`, `access`, `lifecycleState` or `credentialHandoff`. |
| `save_agent` | Seller, Developer | In a Developer account, register a Sales or Creative Agent, rename an owned Agent, or allow certification buys for a Sales Agent. In a Seller account, confirm or correct a Sales Agent product mode. |
| `open_page` | Buyer and Seller | Open an account-appropriate MCP App Page |
| `test_creative_macros` | Buyer and Seller | Dry-run one tracker URL from raw vendor syntax through AdCP, recipient syntax, and synthetic values |
| `open_media_partners_page` | Buyer | Open Media Partners, optionally seeded by a known positive integer-string `advertiserId` or by the seller's Account id as `sellerAccountId`; `connectionAction: "connect"` opens setup and requires `sellerAccountId`; `connectionId` with `connectionAccountId` opens that account's event sources and audiences |
| `open_creative_engines_page` | Buyer | Open Creative Engines, optionally focused on the engine's Account id as `engineAccountId` or an existing `connectionId`; `connectionAction: "connect"` requires `engineAccountId` and focuses secure setup, which the buyer starts in the Page |
| `open_advertisers_page` | Buyer and Seller | Open the Advertisers Page — every advertiser with campaign and draft counts and one next action each; `intent: "add"` opens the Add advertiser form; a Seller Account sees its buyers on the Buyers tab; see [Open Advertisers](https://docs.apostra.com/v2/buyer/advertisers/tasks/open-advertisers-page.md) |
| `open_campaigns_page` | Buyer | Open the Campaigns Page, optionally scoped by a positive integer-string `advertiserId` or focused by `campaignId`; see [Open Campaigns](https://docs.apostra.com/v2/buyer/campaigns/tasks/open-campaigns-page.md) |
| `open_campaign_receipt` | Buyer | Open Review & go live for one draft `campaignId` — plan, staged media buys, and readiness blockers; refuses non-draft campaigns; see [Open Review & go live](https://docs.apostra.com/v2/buyer/campaigns/tasks/open-campaign-receipt.md) |
| `open_approvals` | Seller | Open the seller's governed approvals Page |
| `open_media_buys_page` | Seller | Open Media Buys, optionally scoped to one seller-owned account relationship or seller account mapping |
| `open_seller_dashboard` | Seller | Open Seller Dashboard: seller analytics, or the Demo Storefront evaluation view while the account holds an active Demo Storefront lease; takes no arguments |
| `open_domains_page` | Organisation | Open the organization's Domains Page: each domain the organization claims, whether it is proved and how, the accounts that use it, and whether it is locked because those accounts have advertisers on it. It takes no input and returns no domain state itself; an administrator of the organization, or Apostra staff supporting it, can open it. An administrator claims, changes and proves the domain on the Page; opening it changes nothing; see [Your organization's domain](https://docs.apostra.com/v2/buyer/account/tasks/organization-domains.md#see-the-domains-in-the-domains-page) |
| `open_agent_activity_page` | Developer | Open one agent's Activity Page from its `agentId`: the calls Apostra made to the agent, grouped into certification runs, each with what was sent, what the agent returned and the error, for the account that owns the agent; read-only; see [Read the calls Apostra made to your agent](https://docs.apostra.com/v3/connect-an-agent.md#read-the-calls-apostra-made-to-your-agent) |
| `open_agent_sources_page` | Developer | Open one agent's Inventory sources Page from its `agentId`: the inventory sources the agent powers and the health of each, for the people who can read the agent; read-only; see [Source diagnostics](https://docs.apostra.com/v2/storefront/inventory-sources/diagnostics.md) |
| `open_agent_webhooks_page` | Seller, Developer | Open one agent's Webhooks Page from its `agentId`: the agent's recent inbound webhook outcomes, each refusal with its reason, for the people who can read the agent; read-only; see [Reading your agent's webhook results](https://docs.apostra.com/v3/agent-webhook-deliveries.md) |
| `open_proposal_pass` | Seller | Open the portable Proposal Pass Page for one exact immutable `rfpId`/`turnId` pair; see [RFP lifecycle](https://docs.apostra.com/v3/rfp-lifecycle.md#proposal-pass-the-portable-turn-workbench) |
| `save_session` | Platform operator in a seller account, or a reachable seller in the compose-Session pilot; selected seller at call time | Persist an idempotent private note on an authorised retained Session; seller access is limited to selected compose-run Sessions and it does not send a customer message |
| `save_lesson` | Seller; selected seller at call time | Propose, preview, approve, or retire typed seller guidance; see [Teach from a Session note](https://docs.apostra.com/v2/setup/v3/session-timeline.md#teach-from-a-session-note) |

### Catalogs

Buyer accounts can search and read their advertiser-scoped catalogs with
`search(kind: "catalog")` and
`get(kind: "catalog", id, advertiserId: "12345")`. Use
`save_catalog` to declare one AdCP Catalog or archive it. Item reads are a
bounded `get` include and never expose feed credentials.

### Open Media Buys

`open_media_buys_page` opens the Seller Media Buys Page. It accepts an optional
positive-integer-string `accountRelationshipId` or `sellerAccountMappingId`
(never both) and optional `view` of `media_buys`, `creatives`, or `delivery`.
With neither ID, it opens the Seller's unfiltered Media Buys Page.
`sellerAccountMappingId` is the ID of a company-owned seller account mapping, as
the seller account reads return it. It is not a seller's Account id.

`sellerAccountId` was the earlier name of this filter. A call that still sends
it is refused with a `VALIDATION_ERROR` that names `sellerAccountMappingId`, and
the Page's own data read takes only `sellerAccountMappingId` too.

When an ID is supplied, the tool first proves that the relationship or seller
account belongs to the authenticated Seller Account. An unknown or another
seller's ID fails closed: it returns no Page directive and opens nothing. The
Page then shows only that buyer account's activity with the Seller — its
media buys and synced creatives — never the buyer's own campaigns. A seller
account focus lists the buys routed through that account; buys still routed
under a legacy relationship appear under the relationship focus instead.

### Open Seller Dashboard

`open_seller_dashboard` opens Seller Dashboard. It takes no arguments other than `accountId` and is
listed only for Seller Accounts; a call from another account returns the
error that names the Seller Accounts you can reach and tells you to name one
with `accountId`, and opens nothing.

The Page chooses its own view. While the Seller Account holds an active, unexpired
[Demo Storefront](https://docs.apostra.com/v2/storefront/inventory-sources/demo-storefront.md) lease, it
opens the **Demo Storefront evaluation** view of practice runs, labelled as
synthetic evaluation data. Opening it does not make the Demo Storefront live.
Every other Seller Account sees its seller analytics: the run report (activity
and outcomes, win rate, the posture that converts best, what the agent learned,
seasonality, strategy signals, recommended actions, top buyers and products, and
recent runs) above the RFP metrics. Open a recent run to read its decision
record. The server decides the report's layout, the one insight it leads with and
which optional panels have anything to show; the Page only draws them. The
report and a run's decision record come from two app-only reads,
`seller_dashboard_get_analytics` and `seller_dashboard_get_decision_record`,
which are not model-visible and refuse a call without the Page's own
capability. The Page loads its other data through its own app-only reads.

A storefront whose every active source is your own external sales agent has no
Interchange analytics to show, so the Page shows the connected agent instead,
with the plan it is on (Listing, or Listing and Distribution) and the next steps
that apply: **Upgrade to Public Distribution** or **View plan & billing**,
**Connect an ad server** when merchandising is included but no source can supply
products yet, and **Review source readiness** when such a source exists but is
not ready. The server decides which view and which steps; the Page only shows
them. Two app-only tools serve this view: `seller_dashboard_get_mode` reads the
view and its steps, and `seller_dashboard_open_page` opens only Plan & Billing,
ad-server connection, or one source's diagnostics. Like the Page's other
reads, they are not model-visible and refuse any call that does not carry the
Page's own capability.

Every `search` call must provide `query`, `kind`, or both. An empty call is
invalid. Use `query` for a match term or documentation question and `kind` to
list account objects. To count a kind's objects by one field instead of listing
them, add `groupBy`; see [Count objects with search](https://docs.apostra.com/v3/count-with-search.md).

### Developer accounts

A Developer account is the MCP home for building agents. `get_status` returns
the account's bounded agent list plus its next actions: `connect_agent` and
`connect_buyer_agent` to register an agent you already run, `set_up_hosted_agent`
(listed as unavailable, with the reason, until hosted agents are offered to
Developer accounts), the call that unblocks the agent furthest from done, and
`open_agents_page`, which opens the same view as a page. See
[build an agent](https://docs.apostra.com/v3/build-an-agent.md) or call `search({ kind: "agent" })`. In this account, `search` and `get` can
read the account's agents and the organization's `buyer_agent` principals.
`save_buyer_agent` manages those buyer agents: send no `id` to create one, or an `id` with exactly one
change: rename, lifecycle state, credential handoff, or exact advertiser grants. Grants still name advertisers
inside the organization boundary; a credential is never a grant. Other buyer
and seller operations remain absent.

Use `save_agent` with no `agentId` to register an agent from a Developer
Account. Registration requires an account administrator, `displayName`, `kind`
and an `idempotencyKey`. `kind` is `sales` or `creative`; any other value is
refused. You can select an existing claimed operator or omit `operator` to
create one with the Agent's name. Create a buyer agent with `save_buyer_agent`
instead: it takes no `operator` and no `idempotencyKey`, and the result is a
`buyer_agent` carrying its `principalId`. A repeated buyer-agent request creates
another buyer agent, so `search` before you retry. Send `save_agent` with an
owned `agentId` and a new `displayName` to rename it, `certificationRequested`
to allow certification buys for a Sales Agent (`true`) or to stop sending new
ones (`false`), or both. `search` and `get` return
`certificationRequested` for each agent. See
[Allow certification buys](https://docs.apostra.com/v3/connect-an-agent.md#allow-certification-buys).
Endpoint declarations are immutable revisions, so register them from the Agent
Page rather than through `save_agent`.

### Campaign filters

`search(kind: "campaign")` pages the complete filtered campaign collection.
Pass the returned `nextCursor` with the same filters to continue; `total` is
the filtered total and `truncated` is true only when another page remains.

| Field | Type | Notes |
| - | - | - |
| `filter.campaignStatus` | array | Any of `needs_attention`, `active`, `draft`, `completed`, `paused`, `canceled`, or `archived`. Statuses are ORed; `archived` selects archived visibility rather than an ordinary lifecycle status. |
| `filter.campaignMode` | string | `managed` for platform-authored campaigns or `tracked` for provider-account mirrors. |
| `filter.campaignName` | string | Case-insensitive partial campaign-name match, applied before totals and cursor paging. |

A Buyer can browse the selected advertiser's visible products across eligible
Sellers before creating a campaign:

```json theme={null}
{
  "kind": "wholesale_product",
  "sources": ["objects"],
  "filter": { "advertiserId": "ADVERTISER_ID" }
}
```

Each result includes the exact opaque `productId`, `sellerAccountId`, and Seller name needed for a subsequent
`creative_format` search. Pagination traverses each
qualified product once, even when multiple seller proposals reference it. Pass
`nextCursor` back with the same advertiser and `limit`. When `resultsComplete`
is false, replace the provisional page after polling; when `partial` is true,
do not treat an absent product as unavailable. `persistenceIncomplete`
identifies a terminal partial result where otherwise visible products were
withheld because a complete, buyer-safe identity could not be persisted;
restart without a cursor to retry them. This read may use live or cached
catalogue results and does not create a proposal, campaign, or media buy.
When a Seller answers that your account with it needs setup, its row in
`sellerOutcomes` is finished, not partial: `outcome` is `skipped` with
`reason_code` `account_needs_attention` and a `suggestion` that points to Media
Partners, and the text says the same.

A page can hold fewer rows than `limit` even when more products are waiting.
Each response keeps its product rows to a fixed amount of text, so every row
an MCP host shows arrives whole, with its `productId` and pricing option ids
intact. The rows that don't fit move to the next page: the text says how many
were held back, and `nextCursor` resumes at the first of them. Keep following
`nextCursor` until a page returns none before treating the list as complete,
even when a seller's product count is higher than the rows you have read.

Once `resultsComplete` is true, the response also carries `productQueryId`:
the same id on every complete page of one search. To book products from the
search, pass it as
`idempotencyKey` to `save_media_buy` together with `campaignId`, the product's
`sellerAccountId`, each `productId` unchanged, and `bidPrice` for auction-priced
products (see
[Stage returned products](https://docs.apostra.com/v2/setup/v3/buyer-workflows.md#stage-returned-products)).
One search books into one campaign; search again to book into another.

Each row also carries `pricingOptions`: the product's AdCP `pricing_options`
as the seller sent them (snake\_case, for example `pricing_option_id`,
`pricing_model`, `currency`, `fixed_price` or `floor_price`), at most ten per
product. An option whose `pricing_option_id` is longer than 180 characters, or
whose JSON is larger than 2 KB, is left out, so a row can list fewer options than
the seller offers. Pass the chosen `pricing_option_id` as `pricingOptionId`
when booking. A product with exactly one option books at that option without
`pricingOptionId`; a product with several catalogued options is refused until
you choose one, and the error lists their ids.

To see whether each product can be bought on the advertiser's connected ad
account, add `include: ["executionReadiness"]`:

```json theme={null}
{
  "kind": "wholesale_product",
  "sources": ["objects"],
  "filter": { "advertiserId": "ADVERTISER_ID" },
  "include": ["executionReadiness"]
}
```

Each row then carries `executionReadiness` when its seller reports readiness
(currently Meta, TikTok, Snap, LinkedIn and Google): a summary of whether the product
can be bought on that account and what's missing.

```json theme={null}
{
  "status": "selection_required",
  "slots": [
    {
      "kind": "platform_identity",
      "decision": "explicit_selection_required",
      "total": 3
    },
    {
      "kind": "event_source",
      "decision": "bound_default",
      "total": 1,
      "selected": {
        "providerId": "123456789012345",
        "provenance": "deterministic_default"
      }
    }
  ]
}
```

`status` is `ready_for_execution`, `setup_required`, `input_required`,
`selection_required` or `inventory_unavailable`. Each slot names a resource
the product needs (such as a Facebook Page or a pixel), the `decision` for it,
an optional `reason`, the option already chosen in `selected` (when the seller
picked one), and `total`, the number of options available. The options
themselves aren't listed in this search. Parts of a seller's readiness that
don't match this shape are left out individually, so a row keeps the rest of
its summary. Rows from sellers that don't report readiness have no
`executionReadiness` field. Keep the same `include` when you pass `nextCursor`
back. The include works only for `wholesale_product` on a buyer account.

To list the options for one resource of one product, such as the Facebook
Pages a product can run as, use `include: ["executionResources"]` with the
product's `productId` from the search and the slot's `kind` as
`resourceKind`:

```json theme={null}
{
  "kind": "wholesale_product",
  "sources": ["objects"],
  "filter": {
    "advertiserId": "ADVERTISER_ID",
    "productId": "PRODUCT_ID",
    "resourceKind": "platform_identity"
  },
  "include": ["executionResources"],
  "limit": 100
}
```

Each result row is one option: `providerId`, and where the seller gives them
`name`, `eventType`, `customEventName`, and `relatedResource` (the resource it
belongs to, such as the Page behind a lead form). The response's `resource`
repeats the slot summary for that kind, or is `null` when the seller reports
none for this product (some sellers don't report readiness at all). The first call asks the seller, so it can come back with
`resultsComplete: false`, a `pollAfterMs` hint, and a `nextCursor`: call again
with that cursor until `resultsComplete` is `true`. Pass `nextCursor` back to
get the next page of options; keep the same filter and `limit`. A cursor stops
working after about an hour without use; start again without one. Some sellers
cap a list themselves: LinkedIn returns at most 100 lead forms or conversions,
and Meta at most 200 Messenger welcome flows, Facebook Events, or product sets.

A result carries `overlaySupport` when the Seller declares, in AdCP's own
`overlay_support` shape, which targeting a buyer may set on a package for that
product. When it is present, it is the Seller's complete declaration of the
targeting that product supports. A product with no declaration omits the field, and its
targeting follows the Seller's own declaration, read with `get` on the Seller
(see below). Each declaring product also carries `supportedTargeting`, the names of
the dimensions it declares. A declaration too large to return inline is withheld
rather than cut short, and so is any declaration past a page's total inline
budget (8 KB across its products): `supportedTargeting` is then present without
`overlaySupport`. The text of the result lists each declared dimension
with its systems, such as Nielsen DMA, and its limits.

To read a product's complete declaration, call `get` on that product with the
`productId` from the search and the same `advertiserId` you searched with:

```json theme={null}
{
  "kind": "wholesale_product",
  "id": "PRODUCT_ID",
  "advertiserId": "ADVERTISER_ID"
}
```

The result is the same row search returns, with `overlaySupport` in full (a
seller that targets by region can declare tens of kilobytes of region codes).
A declaration over 256 KB, far larger than any real one, is left out as search
leaves one out, with `supportedTargeting` kept. It reads through the same buyer discovery as
`search`, for that advertiser, so `get` finds only a product the search would
return: any other `productId` answers `NOT_FOUND`. The text of the result stays
short. It summarizes each declared dimension, with long value lists cut to
eight and a count, and says that the complete declaration is in
`structuredContent`. If the seller has not answered yet, `get` returns a
retryable `SERVICE_UNAVAILABLE`: call it again. If the product's Seller answered
that your account with it needs setup (`account_needs_attention`), or is still
being set up (`account_pending`), `get` returns `BUYER_SETUP_REQUIRED` with that
reason in `details.reason`, and a message that points to Media Partners, instead
of `NOT_FOUND`. A Seller Account reads its own
product with `sourceId` and `id` instead, and never passes `advertiserId`.

When the advertiser has property lists, a `wholesale_product` search leaves out
any product that nothing can hold to those lists, and withholds any product
Apostra could not check. It says so rather than returning an empty page that
looks like no inventory. The result carries `unenforceableProductCount` and
`unverifiedProductCount`, and each `sellerOutcomes` entry that had removals
carries `unenforceableProducts` (up to 25, each with its `productId`, `name`,
and the `lists` and `reason` that apply) or `unverifiedProducts` (`productId`
and `name`), with a count and a `...Truncated` flag. A withheld product may
come back on a retry. The fields are absent when nothing was removed. See
[Property lists](https://docs.apostra.com/v2/guides/property-lists.md).

A Creative search must name exactly one owner. Use `filter.advertiserId` to
browse the advertiser's visible Creative summaries, or `filter.campaignId` to
list summaries attached to one campaign:

```json theme={null}
{
  "kind": "creative",
  "filter": {
    "advertiserId": "ADVERTISER_ID",
    "promoted": true
  }
}
```

Advertiser scope supports `formatKind`, `assetType`, `role`, `source`, and
`promoted`. Omitting `promoted` returns all visible Creatives; `true` selects
the promoted reusable shelf, and `false` selects non-promoted Creatives.
Campaign scope supports the top-level `query` only. Each successful response
repeats the effective owner scope and filters in both text and structured
output. Results are plain Creative summary rows; this search does not open or
return a Creative Library Page. Pass `nextCursor` back only with the same
selected account, owner scope, effective filters, and limit.

A Seller asking what it currently offers uses:

```json theme={null}
{ "kind": "wholesale_product" }
```

`total` counts the products that match your `query` and `filter.productStatus`
across the sources read, not every product the Seller holds. `active` counts the
active products on those sources before any filter, so read it beside `total`
only when you sent no filter.

### Conversation history

Buyer and Seller members can explicitly search their prior Apostra rooms with
`kind: "conversation"`:

```json theme={null}
{
  "kind": "conversation",
  "query": "campaign pacing",
  "filter": {
    "conversationStartedAfter": "2026-09-07T00:00:00Z",
    "conversationStartedBefore": "2026-09-08T00:00:00Z"
  }
}
```

The result includes only the member's own rooms and explicitly shared rooms in
the active account. Matches include bounded, display-safe `matchedText`
evidence from the matching title, participant, or message, even when a matching
message is older than the recent transcript tail. Follow a returned id with
`get({"kind":"conversation","id":"..."})` to read up to 20 recent,
display-safe user/assistant messages. That bounded detail read does not limit
the history searched. Tool output and system context are never returned.
Service credentials without an end-user identity fail closed, and conversation
search runs only when explicitly requested; it is not included in an
unqualified cross-kind search.

### Sessions

Retained Session reads are available to signed-in, non-impersonated platform
operators in seller accounts. An ordinary seller can directly search or get an
explicitly source-qualified `storefront_rfp_v1` Session or, when the
server-owned compose-publication rollout selects that seller, a
`storefront_compose_run_v1` Session. Buyer Session access is staged by source.
No buyer source is active today, so buyer Sessions is absent from the rail and
buyer Session reads are denied. When an approved source is active and the
server enables buyer reads for that account, the buyer can open the Sessions
Page and use its buyer-visible reads. Buyers never receive revoked
relationships, other buyers' exchanges, unrelated sellers' exchanges, private
events, seller private notes, or coaching.
`save_session` can add an idempotent private note. An ordinary seller may use
it only for an authorised compose-run Session; use `save_rfp_turn` `feedback`
for RFP-turn feedback. See the
[Session timeline](https://docs.apostra.com/v2/setup/v3/session-timeline.md) for access,
cursor, import-limit, and unsupported-operation boundaries.

### `save_session`: record a private note

`save_session` accepts only `action: "record_note"`. Use the exact
`sessionId` returned by Session search or read, a `clientRequestId`, and the
private `note`. `throughEventId` is optional; when supplied, it anchors the
note to one accessible retained event in that Session.

`sessionId`, `clientRequestId`, and `throughEventId` are opaque strings. They
are trimmed, must not be empty, and can contain at most 512 characters. The
private `note` is also trimmed and required, with a maximum of 8,000
characters.

```json theme={null}
{
  "action": "record_note",
  "sessionId": "SESSION_ID",
  "clientRequestId": "note-001",
  "throughEventId": "EVENT_ID",
  "note": "Explain the price before asking for a commitment."
}
```

The MCP `structuredContent` response is limited to the write result:

```json theme={null}
{
  "action": "record_note",
  "sessionId": "SESSION_ID",
  "receipt": null
}
```

It does not return the full Session or event, and it does not say whether a
note was created or replayed. Re-read the Session with
`get({"kind":"session","id":"SESSION_ID"})` to inspect the retained
note.

The same scoped request key with the same content replays the saved note.
Reusing that key with changed content returns a conflict. The tool never sends
an outbound message. A retry is denied when current access no longer permits
the note, and every action other than `record_note` is unsupported.

For a non-impersonated user who can reach an eligible Seller Account, this tool
can appear before that seller is selected. The call still requires the current
seller's compose-Session rollout and native-room permission check.
Platform operators keep their existing seller-account access. See the
[Retained Session timeline](https://docs.apostra.com/v2/setup/v3/session-timeline.md) for the complete
access and retry rules.

### `save_lesson`: manage seller guidance

`save_lesson` is available in a Seller Account context. Name the Seller
Account with `accountId`. It
uses four actions: `propose` requires a typed `lesson` with kind, scope,
payload, seller words, and one provenance mode. A **Teach this** proposal uses
`sessionId`, `turnOrEventId`, and `noteId`; its seller words must be verbatim
note text. A general proposal that is not taught by a note uses
`sourceRevision` and omits the Session and note ids. `approve` and `retire`
each require a `lessonId`.
`preview` requires a proposed `lessonId` and a `brief`. Preview is ephemeral:
it writes no lesson, composition, provenance, cache entry, or seller history.

A seller operator can propose guidance, including with **Teach this** from a
saved Session note. Only a directly authenticated seller account administrator
can approve or retire a lesson. A retired lesson cannot be approved again, so
propose a new lesson to use that guidance again. An administrator acting through
impersonation cannot approve or retire. See [Teach from a Session note](https://docs.apostra.com/v2/setup/v3/session-timeline.md#teach-from-a-session-note)
for the lesson limits and workflow.

### Seller and product filters

For `search({"kind":"wholesale_product"})`, add `sourceId` to narrow
the list to one ad-server or modular source, or
`filter.productStatus` to select `draft`, `active`, or `archived` products.
Each product carries `origin`: `ad_server` for a product you author on an
ad-server source, `modular_source` for one a modular source offers from its
avails, and `agent_source` for one a third-party sales agent publishes, which
you read by naming that source's `sourceId`. Both catalogue origins are the
products Apostra serves to buyers and proposals. You change them at the source,
not with `save_wholesale_product`. Each product also carries `price`, the price a
proposal quotes for it, and a Demo Storefront product carries `synthetic`.
A Seller Account's product also carries what was saved: `pricingOptions` in
AdCP's pricing option form (`fixed_price` or `floor_price`), `deliveryType`,
`channels` as stored with any guess from its formats in `inferredChannels`,
`formatOptions` as stored, `inventory.publisher_properties`, and
`prebidIntegrationActive`. See
[Prices, delivery type and formats](https://docs.apostra.com/v2/storefront/inventory-sources/create-products.md#prices-delivery-type-and-formats-in-save_wholesale_product).
For Buyer seller reads, `filter.sellerAccountIds` accepts up to 50 exact seller
Account ids in one call, preserves requested order, and reports unavailable ids
as `objects.missingIds`. `filter.ids` is refused on this read with an error that
names `filter.sellerAccountIds`; it is still the filter for `connection` and
`creative_engine` reads.

As an alternative Buyer read scoped to one Seller, call `get` with
`kind: "seller"`, that exact Seller `id`, the current account's
`advertiserId`, and `include: ["products"]`. Preserve the returned
`productQueryId` and catalog revision when polling the same request to a
terminal result; do not reuse a Product from an older catalog.
If the advertiser has no active account with that Seller, it is not searched and
its `catalog.ext.interchange.storefront_results[]` row is `skipped` with
`reason_code` `account_pending` (setup is on its way, nothing to do) or
`account_needs_attention` (the buyer, an operator or the Seller has to act),
and a `suggestion` that points to Media Partners. The text says the same on a
`Not searched` line.
`include: ["products"]` is served only on this Buyer Seller read, on
`proposal`, and on `media_buy`. A Seller Account lists its own products with
`search({"kind":"wholesale_product"})`.

The Buyer Seller read also takes `include: ["commitmentRecord"]`, the seller's
record against its goal commitments, and `include: ["scores"]`, your own scores
with that seller in each campaign you ran with it. See
[Goal-seeking campaigns](https://docs.apostra.com/v2/concepts/goal-seeking-campaigns.md#your-history-with-one-seller).
`scores` is served only on `get`, never on a seller `search`. A Seller Account
asks for `include: ["scores"]` on its own seller read to see its own scores for
each buyer and campaign, at its own net price and without ranks. See
[A seller's own scores](https://docs.apostra.com/v2/concepts/goal-seeking-campaigns.md#a-sellers-own-scores).

`get` on a Buyer Seller also returns `targeting`: the targeting the Seller
declares in its AdCP capabilities (`media_buy.execution.targeting`), with its
systems and limits listed in the text. It applies to that Seller's products
that carry no `overlay_support` of their own. Alongside it, `supportedTargeting`
names the dimensions the Seller declares. As for a product, a declaration too
large to return inline is withheld rather than cut short: `supportedTargeting`
is then present without `targeting`. Both fields are absent when the Seller
declares no targeting, or stores targeting that is not valid AdCP. If the
capability read fails, the whole `get` returns a retryable `SERVICE_UNAVAILABLE`
rather than a Seller that looks as if it declares none.

Every Buyer Seller read and search result also carries `supportsProposals`, a
boolean independent of `transactable`: a seller can be fully transactable and
still not support proposals. A wholesale-only seller — one that sells only
fixed products through `search(kind: "wholesale_product")` and
`save_media_buy`, with no brief or RFP path — declares `supportsProposals:
false`. Check it before calling `request_proposals`; see
[Seller cohort rules](https://docs.apostra.com/v2/setup/v3/buyer-workflows.md#seller-cohort-rules) for
what `request_proposals` does with such a seller.

Buyer `search(kind:"seller")` also accepts these filter fields to narrow results:

| Filter field | Type | Values / notes |
| - | - | - |
| `filter.sellerType` | string | `global_market_maker`, `regional_market_maker`, or `apostra_seller` |
| `filter.transactable` | boolean | `true` returns only sellers you can transact with through Apostra; `false` returns the rest |
| `filter.classification` | string | Deprecated: use `filter.sellerType`. Takes the same values, and `marketplace_seller` means `apostra_seller` |
| `filter.marketplaceReady` | boolean | Deprecated: use `filter.transactable` |
| `filter.channel` | string\[] | Any channel the seller carries (e.g. `display`, `ctv`). Results include sellers that carry at least one of the listed channels. |
| `filter.region` | string | A country the seller sells in: an ISO country code, a country group (`NAM`, `LATAM`, `EMEA`, `EUROPE`, `EU`, `APAC`), or `WORLDWIDE`. A seller that names no country is in every country and matches any value. |
| `filter.partnerStatus` | string\[] | One or more of `ACTIVE`, `NEEDS_CONNECTION`, `AVAILABLE`. Returns sellers with any of these partner statuses. See [Partner status](https://docs.apostra.com/v3/tool-reference.md#partner-status). |
| `filter.advertiserId` | string | Reads each seller's `partnerStatus` for this advertiser instead of the account |
| `filter.marketplace` | boolean | Deprecated: use `filter.partnerStatus`. `true` is `partnerStatus` with all three values |

Send either name of a filter, not both with different values. A request whose
`filter.sellerType` and `filter.classification`, or `filter.transactable` and
`filter.marketplaceReady`, disagree is refused.

### Partner status

Every seller a buyer reads with `search` or `get` carries `partnerStatus`, your
relationship with that seller:

| Value | Meaning |
| - | - |
| `ACTIVE` | The seller is selected for you, or it needs your own login on its platform and you have connected one, or it is a market maker or other automatic-group seller Apostra selects for your account. |
| `NEEDS_CONNECTION` | The seller needs your own login on its platform, you have not connected one, and you have not excluded the seller. This wins over selection. |
| `AVAILABLE` | Everything else, including a seller you excluded. |

`partnerStatus` is read for your account. When the read names an advertiser, by
`filter.advertiserId` on `search` or `advertiserId` on `get`, it is read for
that advertiser, and the three values change in these ways:

* `ACTIVE` means the advertiser qualifies for the seller, or the seller needs
  your own login and you have connected one, whether or not the advertiser
  qualifies.
* `NEEDS_CONNECTION` is unchanged: a seller that needs your login and has no
  connection is `NEEDS_CONNECTION` whichever advertiser qualifies.
* An automatic group or market maker selection does not count on its own,
  because it applies to the account. An advertiser the seller has no record of
  does not qualify.

A seller whose advertiser activation is still provisioning, deferred, or needs
attention stays `ACTIVE`. The Media Partners page shows the same three groups as
its **Active**, **Needs connection**, and **Available** tabs.

`partnerStatus` is separate from `connectionState.status`, which is the technical
state of the connection: `connected`, `needs_account_selection`, `error`, or
`not_connected`. A seller can be `ACTIVE` with a connection that needs attention.

`search({"kind":"seller","filter":{"partnerStatus":["AVAILABLE"]}})` searches every
seller listed on Apostra, so it can return sellers the default list hides. The
default list, with neither `filter.partnerStatus` nor `filter.marketplace`, is
unchanged. `filter.partnerStatus` combines with `filter.sellerType`,
`filter.transactable`, `filter.channel`, `filter.region`, and `query`. It does
not combine with `filter.sellerAccountIds`, which looks sellers up from the default list.
`filter.marketplace: true`, now deprecated, is the same as listing all three
values, and the deprecated name will be removed with the other old seller names.

### Renamed seller fields

Seller reads and `save_seller` use Apostra names. The earlier names still work
for now and are deprecated: reads return both, and `save_seller` accepts either.

| Use | Deprecated name | Notes |
| - | - | - |
| `apostraParticipation` | `marketplaceParticipation` | Seller read and `save_seller` input. Whether the seller takes part in being bought through Apostra after Scope3 review, or has opted out. It is not the Listed on Apostra discovery setting. `PARTICIPATING` is `PUBLISHED` and `OPTED_OUT` is `OPTED_OUT`. A `save_seller` call that sends both with values that disagree is refused. |
| `transactable` | `marketplaceReady` | Buyer seller read, and `controlPlane.selection` on the seller and connection reads. `true` when you can transact with the seller through Apostra now. |
| `unavailableReason` | `marketplaceUnavailableReason` | Same reads. Why `transactable` is false, or `null`. The values are unchanged: `seller_opted_out`, `pending_review`, `not_published`, `cannot_transact`. |
| `sellerType: "apostra_seller"` | `classification: "marketplace_seller"` | Buyer seller read. `sellerType` carries the new value and `classification` keeps emitting the old one, so no caller sees a value it does not know. The other two values are unchanged. |

The deprecated names, including `filter.marketplace`, will be removed no earlier
than 9 December 2026. Read the new names now.

`seller` and `connection` are intentionally separate buyer nouns. A seller is
one exact Storefront commercial counterparty and can exist before authorization.
A connection is one authorization grant, identified by its typed `target`.
Seller grants carry `sellerAccountId`. Enrolled buyers can also connect a
[Creative Engine](https://docs.apostra.com/v2/setup/v3/creative-engines.md) using
`target: {kind: "creative_engine", engineAccountId}` (the engine's Account id)
and search its grants with
`filter.targetKind: "creative_engine"`. Omit that filter to list seller grants.
One seller can have zero, one, or multiple connections, and each connection can
contain multiple discovered provider accounts. Use `search(kind:"connection")`
for questions such as “who am I connected to?” and join the returned
`sellerAccountId` to the seller catalog when names or Apostra policy are needed.

Each connection reports how many advertisers are mapped to its active provider
accounts, and which ones, so a single `search(kind:"connection")` call answers
“is this advertiser mapped here?” without reading each connection.

| Field | Type | Notes |
| - | - | - |
| `mappedAdvertiserCount` | integer | Distinct advertisers mapped to one of the connection's active accounts |
| `mappedAdvertiserIds` | string\[] | Those advertisers' ids, ascending, at most 100 |
| `mappedAdvertiserIdsTruncated` | boolean | `true` when `mappedAdvertiserCount` exceeds the ids returned; `get(kind:"connection")` returns the full mapping rows, paged |

A connection also reports `accountDiscovery`: whether Apostra could read the ad
accounts its sign-in reaches and, when it could not, why and who can fix it. See
[When Apostra can't read a connection's ad accounts](https://docs.apostra.com/v3/connection-account-discovery.md).

### Account inventory

`get(kind:"connection", id, include:["inventory"], connectionAccountId)` lists
the event sources and audiences already on one provider account of the
connection: the pixels, datasets and lists you map your own event sources and
audiences to. `connectionAccountId` is an `accounts[].id` from the same
connection read. The account is read about once an hour, so a new object can
take up to an hour to appear, and the list holds names, counts and health
only, never members or events.

The page is `object.inventory`, 50 rows at a time. When
`inventory.page.nextOffset` is not null, read again with
`connectionInventoryOffset` set to it.

| Field | Type | Notes |
| - | - | - |
| `inventory.items[].objectType` | string | `event_source` or `audience` |
| `inventory.items[].upstreamObjectId` | string | The seller's own id for the object, such as a pixel or dataset id |
| `inventory.items[].name` | string \| null | The seller's name for the object |
| `inventory.items[].managedBy` | string \| null | `seller` marks a platform-owned event source, such as on-site events: it is listed but cannot be mapped |
| `inventory.items[].sellerReport` | object \| null | What the seller reported, in AdCP's shape. Event source: `event_types` (absent means every type), `action_source`, `health` (AdCP `EventSourceHealth`). Audience: `status`, `matched_count`, `effective_match_rate`, `match_breakdown` |
| `inventory.items[].mappedTo[]` | object\[] | Your event sources or audiences mapped to this object on this account, each with `advertiserId`, `eventSourceId` or `audienceId`, `name`, and its sync `status`; at most 10, with `mappedToTotal` counting them all. Empty when the object is only on this storefront |
| `inventory.page` | object | `offset`, `returned`, `total`, `hasMore`, `nextOffset` |

Mapping an event source to one of these objects is `save_event_source`
`sellerAccounts[]`, with this `upstreamObjectId` as the `platformId`; mapping an
audience is `save_audience` `sellerAccounts[]` the same way.

Each `accounts[]` row of a `get(kind:"connection")` read also carries `inventorySummary`, so you can see
what each account holds before reading its inventory:

| Field | Type | Notes |
| - | - | - |
| `inventorySummary.eventSources` | integer | Event sources on the account, mapped or not |
| `inventorySummary.audiences` | integer | Audiences on the account, mapped or not |
| `inventorySummary.readStatus` | string \| null | `listed` when the seller answered, `not_listed` when it cannot list event sources or audiences, `failed` when the last read failed; null until the account has been read |
| `inventorySummary.readAt` | string \| null | When the account was last read; null until it has been read |

A `get(kind:"seller")` call for a single seller also returns
`connectionState.directedCampaignSubscription` when an active directed campaign
subscription exists between the buyer and that seller. The field is absent on
search results and null when no subscription exists.

| Field | Type | Notes |
| - | - | - |
| `connectionState.directedCampaignSubscription.status` | string | `ACTIVE`, `PAUSED`, or `ERROR` |
| `connectionState.directedCampaignSubscription.lastSyncedAt` | string \| null | ISO timestamp of the most recent sync |
| `connectionState.directedCampaignSubscription.lastSyncStatus` | string \| null | `SUCCESS` or `ERROR`; null before the first sync |

## Organization accounts

An Organization account has no operating nouns. Its administrators read child
accounts with `search({kind: "account"})` and `get({kind: "account"})`, and the
Organization's `get_status` reports its child account counts, the accounts it
uses and its Developer account; an organization has no limit on how many accounts it holds, and creation is capped at 25 per organization in 24 hours. `get` with `include: ["lifecycle", "capacity"]` adds one
child's archive state and blockers. `save_account` creates a child (no `id`),
renames one, or archives and restores it with `isArchived`. Each of those writes
returns an `approval_required` link, and an administrator of that organization
approves the exact request on the website. Repeat the call with the same
`idempotencyKey` to see the outcome, or send `dryRun: true` to preview. Send
`organizationId` to act in an organization you are not connected to. The checks
need a direct administrator of that organization, or a Scope3 platform operator
(who alone can create a Demo Storefront); see [Manage accounts with a V3 agent](https://docs.apostra.com/v2/buyer/account/overview.md#manage-accounts-with-a-v3-agent).

**Domains and operator identity.** The Organization's `get_status` also returns
`organization.domains`: each domain the organization has claimed, with:

* `state`: `claimed`, `verifying` while a DNS check is open, or `verified`;
* `proofMethod`: `dns_txt`, `staff_review` or `aao_sign_in`, `null` until
  verified;
* `provedAt`: `null` until verified;
* `accountIds`: the child accounts that use the domain.

An organization that claims nothing returns `domains: []`, and `null` means the
domains could not be read (a gap says so). Apostra staff supporting an
organization can read its `get_status` overview, domains included, but cannot
take its write actions, so their overview carries no `save_account` next step. Each account from `search` and `get`
carries `operatorIdentity`:

* `domain`;
* `unit`: the account's AdCP `operator_unit.id`, `null` while it has none;
* `proofState`: `proved` or `unproved`.

`operatorIdentity` is `null` until the account chooses a domain. These fields
are read-only: claiming and proving a domain is done on the website.

## Internal Apostra administration

Directly authenticated Apostra SuperAdmins may also receive two internal tools:

| Tool | Purpose |
| - | - |
| `preview_marketplace_supply_batch` | Read up to 25 exact Storefronts in one batch and return current revisions, blockers, and proposed Market Maker scope |
| `apply_marketplace_supply_batch` | Apply the confirmed, revision-guarded governed reviews and return one outcome per Storefront |

These tools are omitted from every non-SuperAdmin catalog and re-check staff
authorization at execution. Apply performs a whole-batch stale-state preflight,
then commits each Storefront independently. Its response labels this behavior
`BEST_EFFORT_WITH_BATCH_PREFLIGHT`; callers must inspect every item before
retrying failures.

## Plan and billing

| Tool | Accounts | Purpose |
| - | - | - |
| `get` with `kind: "billing"` | Every account | Read billing readiness: Terms, payment status, plan, offer, renewal, recent invoices and the next action |
| `save_billing` | Every account | Accept Terms, choose Buyer payment terms when available, or accept a plan, answer its offer and change its renewal |
| `request_payment_authority` | Every account | Stage the card handoff for the organization and return a confirmation token |
| `confirm_payment_authority` | Every account | Spend a confirmation token to issue the hosted card-capture link for the cardholder |
| `set_plan_billing_payout_payee` | Buyer, Seller and Organization | Launch the governed payout-payee flow |

### Check and complete Terms setup in text-only chat

Call `get` with `{ "kind": "billing" }`. This read is available before Terms
acceptance, takes no ID or account override, and uses the selected account's
organization. Check `object.organization.customerId` before proposing a write.
The read uses the same billing facts and next-action order as Plan & Billing.

Both response channels contain the organization, readiness, Terms, payment
status and next action, so this flow works in hosts that only expose text.
The response also carries the organization's plan, the offer it can accept at
its exact version, its renewal and its recent invoices. See
[Billing: plan and invoices](https://docs.apostra.com/v3/billing-plan-and-invoices.md) for those fields.
The response omits card details, billing contact details, processor identifiers
and browser credentials.

When an organization manages billing for the selected account and you are not
one of its administrators, the read does not fail. It returns who manages
billing instead, the same answer Plan & Billing shows: `object.organization.name`
names the organization, `object.managedBy.customerId` identifies it, and
`object.changeableBy` is `organization_administrator`, because only an
administrator of that organization can view or change its billing. The
response carries none of the organization's billing details.

| Field | Meaning |
| - | - |
| `terms.requiredVersion`, `terms.url` | Current Terms version and the public document to review |
| `terms.acceptanceRequired`, `terms.canAccept` | Whether acceptance is the current next action and this authenticated user has direct authority on this organization |
| `terms.currentVersionAccepted` | Whether this organization has an acceptance record for the current version. An existing contract or an honoured earlier version can mean acceptance is not required. |
| `nextAction` | The canonical action, its `severity` (`success` when nothing is owed, `warning` otherwise), explanation, documentation link and whether it can currently be completed in chat. `NONE` means no action to take now; read the explanation, which can describe a pending review. |
| `terms.acceptedVersion`, `terms.acceptedAt` | The Terms version this organization last accepted, and when. `save_billing` takes and returns the version under the same name. |
| `paymentAuthority` | Current verification, the latest capture link's `status` and `expiresAt`, submitted setup state and secure handoff availability |

When `nextAction.canCompleteInChat` is `true` and the person has reviewed the
Terms and explicitly authorizes acceptance, call `save_billing` with the exact
`terms.requiredVersion` from that read.
This JavaScript example uses the structured response; a text-only host gets
the same version in the `required version` line:

```javascript theme={null}
const read = await callTool("get", { kind: "billing" });
if (read.structuredContent.object.nextAction.canCompleteInChat) {
  await callTool("save_billing", {
    terms: {
      accepted: true,
      acceptedVersion: read.structuredContent.object.terms.requiredVersion,
    },
  });
}
const after = await callTool("get", { kind: "billing" });
```

`save_billing` takes `terms`, `paymentTerms`, or both, or one plan declaration
(`plan`, `offerResponse` or `renewal`, below), or a `payer` change to the payer
details on invoices (below); a call sends at least one. It has
no `operation` field. Sending Terms and payment terms together is two
independent writes, Terms first. If the payment terms are then refused, the error says the Terms were
accepted. Acceptance requires an authenticated user who is a direct
organization admin, within the credential's organization boundary. A child
account admin is insufficient without direct authority on the parent. A stale
version is rejected; read again and obtain authorization for the new Terms.
Use the subsequent read to verify the same `organization.customerId`, the
accepted version and any remaining action. A successful acceptance returns
`action: "terms_accepted"` with `terms.acceptedVersion`, the field the read
reports.

### Accept a plan, answer its offer or change its renewal

Three `save_billing` declarations act on the organization's IU plan. Each is
sent alone (not beside `terms` or `paymentTerms`), is
pinned to the version of the offer or renewal the person was shown (read
`plan.offer.offerVersion` and `plan.renewal.version` from `get` with
`kind: "billing"`, name a plan by the `planId` that read lists under
`plan.offer.plans`, which is the plan's code, and send a renewal action only
from `plan.renewal.allowedActions`), takes an
`idempotencyKey`, and needs the same direct organization authority as accepting
Terms: a direct administrator of the organization, or a Scope3 staff member
holding an active staff elevation for it (`terms.canAccept` on the billing read
says whether the caller has it). A `plan` is narrower: only a direct
administrator of the billing organization may ask for it or approve it, so a
staff elevation does not apply. The governing Terms must already be accepted.

| Field | Input | What it does |
| - | - | - |
| `plan` | `plan`: `offerVersion`, `planId`, `paymentOptionKey` when the offer lists payment options, `idempotencyKey` | Asks an administrator to accept the plan, or to switch to it when the organization already has one, on the Apostra site. Send the server's `optionKey`, never a price. |
| `offerResponse` | `offerResponse`: `offerVersion`, `outcome`, optional `planId`, `idempotencyKey` | Records `DECLINED`, or `DEFERRED` to decide later, for that offer. It changes no billing, access, meters or invoices. |
| `renewal` | `renewal`: `renewalVersion`, `action`, `reason`, `idempotencyKey` | Applies one of the renewal's allowed actions: `CANCEL_AT_TERM` or `REINSTATE_AUTO_RENEWAL`. The `reason` is kept in audit history. |

```json theme={null}
{
  "plan": {
    "offerVersion": "v1:7f3a…",
    "planId": "GROWTH",
    "paymentOptionKey": "monthly",
    "idempotencyKey": "plan-2026-10-17-0001"
  }
}
```

Accepting a plan commits the organization to its price, so a `plan` call never
accepts on the call. It checks the plan against the current offer, records the
request, and returns `action: "approval_required"` with a link to the Apostra
site ([Approving agent requests](https://docs.apostra.com/v2/concepts/web-approvals.md)). Show the person
the link. A direct administrator of the billing organization signs in, sees the
exact plan, price, payment option and start, and approves or rejects it; the
person who asked may approve it, and so may any other direct administrator. An
administrator of a parent organization who is not a direct member, Apostra
staff acting for the organization without a direct administrator membership,
API keys and impersonated or support sessions cannot approve it. Nothing
is accepted until someone approves, and the request expires after 24 hours.

Repeat the same call with the same `idempotencyKey` to check the outcome. While
the approval waits or runs it returns `approval_required` with its `state`.
Once it has succeeded it returns `action: "plan_accepted"` with the plan's
`bindingId`, `planId` (the same plan code the billing read lists), `planName`,
`paymentOptionKey`, `termStart` and `termEnd`. Its `takesEffect` is `now` when the plan is in force and
`at_term_end` when it starts as the current term ends. The accepted plan is
recorded as accepted by the administrator who approved it. A `rejected`,
`expired` or `failed` approval accepts nothing; start again with a new
`idempotencyKey`. `offerResponse` returns `action: "offer_response_recorded"`
and `renewal` returns `action: "renewal_changed"`, each on the call. Repeating
a call with the same `idempotencyKey` and the same input returns the original
result; reusing the key for a different choice is a `CONFLICT`.

A version that is no longer current is refused and nothing changes. The error
has `code: "CONFLICT"`, `field` naming `offerVersion` or `renewalVersion`, and
`details` with `reason: "STALE_VERSION"` and `currentVersion`, the version now
in force. Show the person what changed, then send the new version only if they
still agree. A caller without that authority, neither a direct organization administrator nor
staff with an active elevation, receives `ACCESS_DENIED`, before any version is
compared. Neither `SHOWN` nor a price is
accepted, and a `plan`, `offerResponse` or `renewal` sent beside another field is refused by name.

Apostra's chat assistant follows the same path: it shows the approval link,
and the plan is accepted only once an administrator approves it on the site.
In the plan widget the person accepts in the widget.
To ask Apostra for plan help or a private offer, file a `save_ask` of type
`commercial`: the server attaches the organization's plan context to it, so
the caller does not send it.

### Add billing details for consolidated billing

When `get_status` lists `billing_details` in `blockers`, the billing
organization is missing a payer name, street address or country, and the buyer
buys from a consolidated-billing seller. Add them with `save_billing` and
`payer`, or in Plan and billing; the blocker clears on the next `get_status`.
The billing read returns the details as `payer`, and `payer.complete` is `false`
while any of the three is missing.

`payer` is sent alone, takes only the fields to change (a field left out stays,
`null` clears it) and an `idempotencyKey`, and returns `action:
"approval_required"` with a link: a direct administrator of the billing
organization approves the change on the Apostra site, and nothing is written
until then. Repeat the same call with the same `idempotencyKey` to check the
outcome; once approved it returns `action: "payer_saved"` with `payer`. A
`rejected`, `expired` or `failed` approval changes nothing; start again with a
new `idempotencyKey`.
[Billing: plan and invoices](https://docs.apostra.com/v3/billing-plan-and-invoices.md#change-the-payer-details)
lists every field.

### Choose payment terms for consolidated billing

When `get_status` reports a required `payment_terms` next action, an
organization administrator must choose the terms that sellers are asked for
before the buyer can use consolidated billing. The next action names
`save_billing` with `paymentTerms: "net_60"`, which is the default choice; use
that value when the buyer wants the default. Otherwise, confirm the buyer's
choice and send one of `net_15`, `net_30`, `net_45`, `net_60`, or `net_90`:

```json theme={null}
{
  "paymentTerms": "net_45"
}
```

Do not send `null` to restore a default; `null` is refused. Send `net_60` instead.

The successful response has `action: "payment_terms_saved"` and echoes both
`paymentTerms` and `paymentTermsChosen: true`. The recorded choice belongs to
the billing organization, including when its administrator works in a child
Buyer Account context.

Organization (parent-container) sessions can read billing readiness and call
`save_billing`. So can an organization administrator working inside a child
account. In both, the write succeeds only for a direct administrator of the
billing organization: for Terms, a plan or offer decision, a renewal change,
payment terms and the card handoff, the service refuses anyone else.
`nextAction.canCompleteInChat` is `true` when `terms.canAccept` is `true` and
Terms are the next action. Payout setup stays on the Plan & Billing Page: its
`set_plan_billing_payout_payee` action runs in the Organization session. A payout
payee set there is the organization's default for every Seller Account without
its own payee. The organization must have at least one Seller Account.

If the account is already governed by a custom contract, no standard Terms
acceptance is recorded. The tool returns `action: "terms_not_required"` with
`terms.accepted: false` and `terms.governedByExistingContract: true` so the
agent does not report a legal acceptance that did not occur.

### Payment status and the remaining handoff gap

The billing read does not add a payment-method write or a secure one-time cardholder
handoff. It returns `paymentAuthority.secureHandoff.available: false`. The reason is
`secure_handoff_unavailable`, or `card_collection_not_enabled` for an
organization outside the card-collection rollout. This field describes the
missing one-time ceremony, not availability of the existing capture-link tool.

For payment authority, call `request_payment_authority`, which takes no input
beyond the account. It returns `confirmation_required` with a single-use
confirmation token. Then call `confirm_payment_authority` with that
`confirmationToken`. It returns `human_action_required` with the cardholder URL.
Each is its own tool because each call is an event: a second request stages a
new handoff, and a confirmation issues a link for a person to open. They need
the same write permission and card-collection availability as the capture-link
contract always did. That
URL can be reopened until verification or expiry. It does not provide a
one-time browser exchange with fresh human proof, and is not the secure handoff
required to complete the new text-only setup flow. Do not describe that missing
capability as complete or ask for card details in chat.

For current payment readiness, poll `get` with `{ "kind": "billing" }` every
15–30 seconds; this read is the status of the handoff, and there is no status
tool. Its `paymentAuthority` fields distinguish current verification
from setup history. `status` and `expiresAt` describe the latest capture link:

| Evidence | Interpretation |
| - | - |
| `status: null` | No capture record exists; this does not prove a request was staged or confirmed |
| `status: "pending"` | A legacy request was confirmed and a capture was issued; no card is verified by this fact |
| `status: "opened"` | The capture was accessed; opening it is not fresh human proof or card verification |
| `status: "expired"` | The capture expired |
| `status: "verified"` | That capture was verified historically; the card may since have been removed |
| `submission.status` | `processing`, `verified`, `failed` or `expired` from the latest submitted secure card form; `null` means no current submitted attempt |
| `verified: true` | The canonical billing account currently has a verified payment method |

`save_billing` no longer takes the earlier `operation` and `paymentAuthority`
fields (`request`, `confirm` and `status`); a call that sends either is refused
with a validation error. Send `request_payment_authority`,
`confirm_payment_authority` and the billing read instead. The billing read's
`paymentAuthority` contains `method: "capture_link"`, `status` and `expiresAt`
for the latest capture link; no capture record returns `status: null`. It does
not report the current payment method or submitted form state on its own: use
`paymentAuthority.verified` and `payment.paymentMethod` for those facts.

After verification, read billing readiness again. A verified payment method
does not imply every billing action is complete. Use the canonical next-action
explanation to distinguish good standing from a pending decision. This read
does not grant purchasing or product access. If the next action cannot be
completed in chat, explain that limitation and use its documentation link.
The new text-only readiness flow stops before card setup. A versioned one-time
handoff with fresh cardholder confirmation is still needed. The existing
capture-link contract continues to serve deployed clients.

Legal acceptance and payout changes remain authorization-sensitive. The three
named Plan & Billing tools in this table are private Page capabilities and may
be omitted from model context. Follow the tool response and Page requirements;
never pass card or bank details through free text.

## Buyer tools

| Tool | Purpose |
| - | - |
| `save_advertiser` | Create or patch an advertiser, including inherited tracking defaults. Your agent may suggest markets and channels; confirm or change them before saving. Archive or restore one advertiser (`advertiserId`) or several in one call (`advertiserIds`) |
| `save_buyer_operator` | Confirm the buyer account's commercial operator identity (domain and operating-unit code) used with sellers; this does not grant login access |
| `save_campaign` | Create or patch a campaign, including a per-seller MediaBuy frequency cap, local trackers, and inherited-tracker overrides; set the full set of creatives on it with `creativeIds`; pause, archive, restore, reactivate, explicitly launch, or request asynchronous cancellation of it and every nonterminal media buy |
| `save_dimension` | Create or update a buyer-owned dimension and its values; labels remain fields on advertisers and campaigns |
| `upload_creative_asset` | Open the private upload Task for one buyer-owned advertiser; no destination connection or provider account is required |
| `save_creative` | Create, update, or archive a creative; use `sourceAssetRef` for one finalized image or `sourceAssets` with the selected `creativeFormatId` to bind finalized images and promoted MP4s to canonical format slots; set the full set of campaigns an advertiser creative runs on with `campaignIds`; save an approved Creative Session output to an advertiser Library with `fromCreativeSessionOutput` |
| `save_creative_session` | Save a Creative Session: a brief with `draft`, the output you select or approve, or the approved output you finalise, one change per call. It never generates; `generate_variants` does |
| `save_creative_collection` | Create or change a non-executable advertiser or campaign collection; advertiser writes use a revision and saved-creative members only |
| `save_audience` | Sync first-party CRM audiences for an advertiser; add, remove, or delete an audience, map it to the audience already on each seller account, and then read its match status |
| `save_event_source` | Create, change, archive, or restore up to 50 conversion event sources (pixels, server feeds) for an advertiser in one call; each source succeeds or fails on its own |
| `log_event` | Send up to 10,000 conversion events (AdCP `log_event`) to an event source registered with `save_event_source`; the whole call is accepted or refused |
| `request_proposals` | Ask every seller currently eligible for the buyer for proposals or products against one campaign revision. The campaign must target at least one country with `targetingOverlay.geo_countries` (directly, or through `targetingOverlay.geo_regions`, `targetingOverlay.geo_metros`, or a postal area); a campaign with none is refused before any seller is contacted. When the campaign has channel groups, only sellers whose declared channels can match a group are contacted; the others are listed in `skippedSellers`, and returned products outside every group are dropped. When the advertiser has an inclusion property list, the response provides bounded property coverage and missing-property reasons; partial seller evaluation is marked incomplete rather than presented as exact coverage. |
| `save_media_buy` | Accept a proposal, stage returned products, update a draft media buy (flight, budget, bid, pricing option, targeting overlay, package performance standards, line items by `lineItemRef`), or archive an unwanted draft; repeat a product as an additional line item with `products[].selectionId`; change a live buy's budget, bid, flight, or line-item targeting, or a running buy's end date (resend its current `startAt`) (`pending_approval`, with the change in `mediaBuy.pendingChange`, while it is not live yet); pause or resume one buy with `isPaused` (`pause_requested` / `resume_requested` while the seller is still applying it) |
| `get_delivery` | Query bounded Buyer campaign delivery |

Buyer `search` and `get` kinds include the account's advertisers, campaigns,
creatives, creative formats, creative collections, wholesale products,
proposals, media buys, sellers, asks, and organization-owned Agents, plus other
kinds advertised by the live schema. Follow the [Buyer
workflows](https://docs.apostra.com/v2/setup/v3/buyer-workflows.md) page for sequencing.

### Update media-buy targeting

Use `save_media_buy` to update `products[].targetingOverlay` while the media buy
is a draft, or on a live buy whose seller takes the change
(`liveChanges.changeTargeting` is not `not_accepted`; send it alone). A buy that
has left draft and whose seller does not take the change is refused before it is
saved. See [Change a live media buy](https://docs.apostra.com/v3/change-a-live-media-buy.md). Country
inputs must be ISO 3166-1 alpha-2 codes and region inputs must be ISO 3166-2
codes; names are display labels and are rejected as update input.

The overlay can only narrow the campaign targeting. Each changed or cleared
dimension is checked against the seller for that product before the update is
saved. Omit a key to keep the buy-level value, or send `null` to clear it and
inherit the campaign's value again.

### Inspect Seller operations

For a media buy, `get` includes a Seller-operation summary when work exists.
Add `include: ["operations"]` for the bounded recent change list. A capped
destination summary says `truncated: true`; a failed durable-store read says
`status: "unavailable"`, rather than implying no changes. See the
[media-buy read workflow](https://docs.apostra.com/v2/setup/v3/buyer-workflows.md#inspect-staged-media-buys)
for the states and follow-up actions.

### Say whether a line item may start before every creative is approved

Pass `startRequiresAllCreativesApproved: true` on a `save_media_buy` product when
you create the buy to keep that line item from going live before every creative
on it is approved: the buy waits for the seller's review instead of booking with
some of the creatives. Omit it, or send `false`, to book with the approved
creatives and attach the rest when they are approved. An update that names it
returns `NOT_IMPLEMENTED`. See
[Creative sync outcomes](https://docs.apostra.com/v3/creative-sync-outcomes.md#say-whether-a-line-item-may-start-before-every-creative-is-approved).

### Set package performance standards

Pass `performanceStandards` on a `save_media_buy` product when the buyer needs
a package-level commitment. It is a non-empty list. Each entry has one metric
(`viewability`, `ivt`, `completion_rate`, `brand_safety`, or
`attention_score`) and a `threshold` from 0 through 1. Viewability also
requires `standard: "MRC"` or `standard: "GroupM"`; do not send `standard` for
the other metrics. IVT is a maximum. Every other metric is a minimum.

You may name a measurement vendor with
`vendor: { "domain": "vendor.example", "brand_id": "optional-id" }`. The
vendor must be a brand reference: `domain` is required and `brand_id` is
optional.

AdCP's standard names the vendor but not which of the vendor's metrics holds the
value, and a vendor can report several (for example more than one invalid traffic
rate). To have Interchange judge an `ivt`, `brand_safety` or `attention_score`
standard, name the metric in `ext.apostra.metric_id`, the identifier from the
vendor's own metric list (lowercase letters, digits and underscores). It needs
`vendor` too, and is refused on `viewability` and `completion_rate`. Interchange
uses it only to judge delivery: it never sends `ext` to the seller. `ext` accepts
the `apostra` namespace only and refuses any other. Only V3 `save_media_buy` takes
`ext`; the V2 media buy tools and reads do not carry it. This is an extension while
the [proposal to carry the metric on the AdCP standard itself](https://github.com/adcontextprotocol/adcp/issues/8394)
is with AdCP; it is removed once AdCP ships the equivalent.

Only send these terms for a product or seller that declared performance-standard
support in discovery. At launch, Interchange forwards the standards, without
`ext.apostra`, when that support is present. Otherwise it refuses the launch with
`SELLER_PERFORMANCE_STANDARDS_UNSUPPORTED`; it never omits the terms silently.
Use `get(kind: "media_buy", include: ["products"])` to read the accepted
standards from each package.

```json theme={null}
{
  "products": [
    {
      "productId": "qualified-product-id",
      "performanceStandards": [
        {
          "metric": "viewability",
          "threshold": 0.7,
          "standard": "MRC",
          "vendor": { "domain": "measurement.example" }
        },
        {
          "metric": "ivt",
          "threshold": 0.02,
          "vendor": { "domain": "measurement.example" },
          "ext": { "apostra": { "metric_id": "givt_rate" } }
        }
      ]
    }
  ]
}
```

### Sync a first-party audience

Use `save_audience` to send a batch of first-party CRM audience changes for an
advertiser that the active Buyer Account can write. Each item has a buyer-owned
`audienceId` and can add members, remove members by `external_id`, or delete the
audience entirely. Deleting an audience is destructive.

Members are AdCP `AudienceMember` objects. Provide each added member's
`external_id` and at least one identifier: a SHA-256 `hashed_email` (of the
trimmed, lowercased email), a SHA-256 `hashed_phone` (of the E.164 number), or
`uids`, AdCP's platform user IDs such as `{ "type": "uid2", "value": "..." }`.
Hashes are lowercase hex. Hash emails and phone numbers before sending them: a
member with a raw `email`, or with AdCP's `ext`, is refused. Do not put raw PII
in `external_id`.

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "audiences": [
    {
      "audienceId": "loyal-customers",
      "name": "Loyal customers",
      "consentBasis": "consent",
      "add": [
        {
          "external_id": "crm-123",
          "hashed_email": "SHA256_OF_NORMALIZED_EMAIL"
        }
      ]
    }
  ]
}
```

A successful save returns `action: "synced"`, an `operationId`, the
`advertiserId`, and `audienceCount`. The operation is asynchronous. Read the
advertiser's current audience status and uploaded and matched counts with
`get({ kind: "audience", advertiserId: "ADVERTISER_ID" })`; there is no
separate audience-status tool. Each audience reads back under the names
`save_audience` takes (`audienceId`, `name`, `consentBasis`, `sellerAccounts`),
with `status`, `uploadedCount`, `matchedCount`, `lastOperationStatus`,
`createdAt`, and `updatedAt` beside them. `status` is AdCP's `AudienceStatus`:
`processing`, `ready`, or `too_small`. Target an audience once it is `ready`.
An audience whose last sync failed has no `status`; it has an `errors` list
with one AdCP error (`code: "SERVICE_UNAVAILABLE"`, `recovery: "transient"`),
and sending the same members again with `save_audience` retries it.
`lastOperationStatus` is the latest sync's state: `processing`, `completed`,
or `error`. `uploadedCount` and `matchedCount` are Interchange's own totals
across the audience. The V3 audience workflow
does not support the V2 missing-audience sweep or push-notification callback.
To remove an audience, send that audience's `audienceId` with `delete: true`.

To say which audience already on a seller account this audience is, add
`sellerAccounts` to its entry: up to 50 pairs of `accountId`, the seller
account's `accounts[].id` from
`get({ kind: "connection", id: "CONNECTION_ID" })`, and `platformId`, the id of
the audience on that account's platform (for example a Meta Custom Audience ID). One
save can name at most 200 seller accounts across all of its audiences. The
audience must already exist: save its members first, and map it once `get`
lists it. An entry that deletes an audience cannot also map it.

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "audiences": [
    {
      "audienceId": "loyal-customers",
      "sellerAccounts": [
        { "accountId": "SELLER_ACCOUNT_ID", "platformId": "SELLER_AUDIENCE_ID" }
      ]
    }
  ]
}
```

Saving records the mapping and does not itself contact the seller. It also
asks Interchange to check that the audience is on that seller account, and the result appears on
the audience's `sellerAccounts` entry for that account. An entry that only maps seller accounts sends no members, and when
every entry only maps, the response's `operationId` is `null`. Saving again
with a different `platformId` changes the audience for that account. Accounts
you leave out of a later save keep their mapping. The save is refused with
`REFERENCE_NOT_FOUND`, naming the `field` at fault, and nothing is saved or
sent, when an `accountId` is not an active, connected account of yours, or
when the `audienceId` does not exist for the advertiser yet. The mappings are
saved before any members are sent, so if sending members then fails, the
mappings stay saved and the whole save can be sent again.

To unmap an account, send it with `unmap: true` in place of `platformId`:
`{ "accountId": "SELLER_ACCOUNT_ID", "unmap": true }`. Unmapping ends
Interchange's mapping on that account only; it never deletes or changes the
audience on the seller's account. The account no longer appears in
`sellerAccounts`, and mapping it again later starts it at `pending`. Unmapping
an account that is not mapped changes nothing, and an account can be unmapped
after its connection has been disconnected. While a live media buy still uses
the mapping, the save is refused with `INVALID_STATE` on that account's
`unmap` field, naming the media buys, and nothing is saved or sent; unmap it
once they have ended or been canceled.

On the `get` read, `sellerAccounts` lists the seller accounts the audience is
mapped to. It is empty until you map one. Each entry has the account's
`storefront` (`id` and `name`), its `accountId`, `platformId`, `status`,
`lastSyncedAt`, `match`, and, while the account cannot use the audience yet, a
`blocker` with a `code` and `message`. `platformId` is the audience id you
mapped on that account, from the moment you save it. `status` says whether
Interchange has confirmed it: `pending`, with a blocker naming what it is
waiting for or what to fix, until the seller account confirms the audience,
then `synced`. `lastSyncedAt` and `match` describe the audience last confirmed
on the account, so while a changed mapping is `pending` they still describe the
earlier audience.

`sellerAccounts[].match` is the seller's own size and match rate for the
audience on that account, in the AdCP `sync_audiences` result shape:
`matched_count`, `effective_match_rate`, and `match_breakdown` (per identifier
type: `id_type`, `submitted`, `matched`, `match_rate`). It is `null` until the
seller reports them. Sellers differ: the same audience can match 60% of its
members on one account and 20% on another, and some sellers report no match
rate at all.

### Save event sources

Use `save_event_source` to register the conversion event sources (pixels,
server feeds, CRM or warehouse syncs) that an advertiser sends events from.
One call covers one advertiser and up to 50 sources. Each entry in
`eventSources` is keyed by your own `eventSourceId`: a new id creates the
source, an existing id updates it. `name` is required when an entry creates a
source; Interchange never fills it in for you.

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "idempotencyKey": "sync-ADVERTISER_ID-2026-09-29-001",
  "eventSources": [
    {
      "eventSourceId": "purchase-completed",
      "name": "Purchase Completed",
      "eventTypes": ["purchase"],
      "actionSource": "website",
      "valueCurrencies": ["USD"]
    },
    { "eventSourceId": "legacy-pixel", "isArchived": true }
  ]
}
```

To say which pixel a source uses on a seller account, add `sellerAccounts`
to its entry: up to 50 pairs of `accountId`, the seller account's
`accounts[].id` from `get({ kind: "connection", id: "CONNECTION_ID" })`, and
`platformId`, the id of the pixel on that account's platform (for example a
Meta dataset or a Snap Pixel). The pixels on an account are listed by
`get({ kind: "connection", id: "CONNECTION_ID", include: ["inventory"], connectionAccountId: "SELLER_ACCOUNT_ID" })`
(see [Account inventory](https://docs.apostra.com/v3/tool-reference.md#account-inventory)). Name the pixel on every account the source should run on:
Interchange never picks one for you, even when the account has only one. The
one exception is a source whose own `eventSourceId` is exactly the id of a
pixel on the account: with no mapping named, it uses that pixel. A
pixel that lists the event types it accepts must accept every type in the
source's `eventTypes`; a source without `eventTypes` binds only to a pixel
that lists none. One save can
name at most 200 seller accounts across all of its event sources; a larger
save is refused with `VALIDATION_ERROR` before anything is saved.

```json theme={null}
{
  "eventSourceId": "purchase-completed",
  "sellerAccounts": [
    { "accountId": "SELLER_ACCOUNT_ID", "platformId": "PIXEL_ID" }
  ]
}
```

Saving records the request and does not contact the seller. When the
account's inventory already lists the pixel with the event types it accepts,
and they do not cover the source's `eventTypes` (a source without `eventTypes`
is used for every type, so a pixel that lists any types does not cover it), the
entry fails with `VALIDATION_ERROR` on that entry's `platformId` field and the
source is not saved; name a pixel that accepts those types or change the
source's `eventTypes`. A pixel the inventory does not list yet, for example
because the account has not been read since you connected it, is saved as named.
Interchange checks the pixel when the source syncs to the account. Saving a
new or changed pixel starts that check straight away, so `sellerAccounts`
shows the pixel `synced` or names what to fix without waiting for a media buy.
Saving the same pixel again starts nothing new. The check sends none of your
conversion data, so it does not depend on the **Conversion events** switch.
See
[how a source reaches each seller account](https://docs.apostra.com/v2/guides/measurement-incrementality.md#how-a-source-reaches-each-seller-account)
and [Use your event sources and audiences on each seller account](https://docs.apostra.com/v2/guides/map-event-sources-and-audiences.md).
Saving again with a different `platformId` changes the pixel for that account.
If the account already uses a confirmed pixel, it keeps using that pixel
until Interchange confirms the new one. If the account's sync `failed`,
`expired`, or was `removed`, naming a pixel again returns it to `pending` so
it can be retried. A save that changes only an event source's seller
accounts reports `updated`.
Accounts you leave out of a later save keep their binding. An `accountId`
that is not an active, connected account of yours fails the entry with
`REFERENCE_NOT_FOUND`, and the source is not saved.

To unmap an account, send it with `unmap: true` in place of `platformId`:
`{ "accountId": "SELLER_ACCOUNT_ID", "unmap": true }`. Unmapping ends
Interchange's binding on that account only; it never deletes or changes the
pixel on the seller's account. The account no longer appears in
`sellerAccounts`, a new media buy on it fails as it would for a source that
was never bound there, and naming a pixel again later starts the account at
`pending` until Interchange confirms it. Unmapping an account that is not
bound changes nothing, and an account can be unmapped after its connection
has been disconnected. While a live media buy uses the binding, the entry
fails with `INVALID_STATE` on that account's `unmap` field, naming the media
buys, and the entry is not saved; unmap it once they have ended or been
canceled.

Send `isArchived: true` to archive a source, and `isArchived: false` to undo
the archive with its configuration kept. An entry that sets `isArchived`
carries no other change. Campaign optimization goals that reference an
archived source stop resolving it until the archive is undone.

The response has one entry per source you sent, in the same order, each with
its own `action`:

* `created`, `updated`, or `unchanged`: the full event source, plus `setup`,
  which links the guide for sending events. Undoing an archive reports
  `updated`.
* `archived`: the `eventSourceId` only.
* `failed`: the `eventSourceId` and `errors` in the AdCP error shape. Each
  error has a `code`, a `message`, a `recovery` hint, and, where it applies,
  the `field` at fault:
  * `VALIDATION_ERROR` (`correctable`): the entry is invalid, for example a
    new source sent without a `name`.
  * `REFERENCE_NOT_FOUND` (`correctable`): an entry that sets `isArchived`
    named an `eventSourceId` the advertiser does not have, or a
    `sellerAccounts` entry named an `accountId` that is not connected.
  * `INVALID_STATE` (`correctable`): a `sellerAccounts` entry asked to unmap
    an account that a live media buy still uses.
  * `SERVICE_UNAVAILABLE` (`transient`): the entry could not be saved; send it
    again with a new `idempotencyKey`.

One failed entry does not stop the others. The `idempotencyKey` covers the
whole call: repeating the same call with the same key returns the first
result, including any failures, with `replayed: true`. To retry a failed
entry, send it again with a new key.

Each event source reports `health` in the AdCP `EventSourceHealth` shape.
`health.status` is the AdCP grade (`insufficient`, `minimum`, `good`, or
`excellent`), `health.last_event_at` is when Interchange last received an
event from the source, and `health.issues` lists anything that needs
attention, such as no events received yet or a rejected event. Registering a
source does not prove that events arrive: read it with
`get({ kind: "event_source", id: "EVENT_SOURCE_ID", advertiserId: "ADVERTISER_ID" })`
before relying on it.

`sellerAccounts` lists the seller accounts the source is bound to. It is
empty until you name a seller account. Each entry has the account's
`storefront` (`id` and `name`), its `accountId`, `platformId`, `status`,
`lastSyncedAt`, `health`, and, while the account cannot use the source yet, a
`blocker` with a `code` and `message`. `platformId` is the pixel you named on
that account, from the moment you save it; for a source that uses the pixel
with its own id, it is that pixel once confirmed, and `null` before. `status`
says whether Interchange has confirmed it: `pending`, with a blocker naming
what it is waiting for or what to fix, until a sync confirms the pixel, then
`synced`. When you change the pixel on an account that already has a
confirmed one, `platformId` shows the new pixel and the account reads
`pending` until Interchange confirms it, or names why the seller refused it.
Meanwhile buys already on that account keep using the confirmed pixel, and
`lastSyncedAt` and `health` still describe it.

`sellerAccounts[].health` is the seller's own assessment of the confirmed
pixel, in the AdCP `EventSourceHealth` shape, as the seller reported it when
Interchange confirmed the pixel. It is `null` until a pixel is confirmed, and
`null` when the seller gives no assessment. A pixel that is set up but has
not received events yet reads `events_received_24h: 0` with no
`last_event_at`; send events to it before relying on it for optimization.
Interchange returns it as the seller reported it. The source's top-level
`health` is Interchange's view of the events it received;
`sellerAccounts[].health` is the seller's view of its pixel.

A seller account that cannot take event sources at all reads
`status: "failed"` with the `SELLER_OBJECT_TYPE_UNSUPPORTED` blocker. These
states never block saving or launching a campaign; a campaign's
`optimizationGoals` name event sources by `eventSourceId`, and their state on
each seller account is read here.

### Log events

Use `log_event` to send conversion events to an event source the advertiser
registered with `save_event_source`. One call covers one source and up to
10,000 events. Each event is an AdCP `Event` in AdCP's snake\_case fields:
`event_id`, `event_type`, `event_time`, and optionally `user_match`,
`custom_data`, `action_source`, `surface`, `event_source_url`, and
`custom_event_name`. Some AdCP fields are accepted but not stored yet, among
them `surface`, `ext` and `custom_data.content_name`. When an event carries
one, the call succeeds and `ingestion.warnings` names each such field.

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "eventSourceId": "purchase-completed",
  "idempotencyKey": "events-ADVERTISER_ID-2026-10-26-001",
  "events": [
    {
      "event_id": "order-1001",
      "event_type": "purchase",
      "event_time": "2026-10-26T14:30:00Z",
      "user_match": { "hashed_email": "SHA256_OF_LOWERCASE_EMAIL" },
      "custom_data": { "value": 42.5, "currency": "USD" },
      "action_source": "website",
      "event_source_url": "https://shop.example.com/checkout"
    }
  ]
}
```

The whole call is accepted or refused. It is refused with
`VALIDATION_ERROR` when an event is not a valid AdCP `Event` (for example a
negative `custom_data.value`, a `uids[].type` outside AdCP's list, or a
`custom_data.contents[]` item without an `id`; over the HTTP API such a body
is refused before the tool runs, with `INVALID_REQUEST` and HTTP 422), when
an event breaks an AdCP rule the published schema does not carry (a
`user_match` with no identifier, an empty `user_match.uids` list, or a
`watch_milestone` event with neither `custom_data.progress_percent` nor
`custom_data.progress_seconds`), when two events in the call share an
`event_type` and `event_id`, when `user_match.uids[].value`,
`user_match.click_id`, a `surface` string, or `custom_data.content_name`,
`content_category` or `search_string` looks like a raw email address or
phone number (send `hashed_email` or `hashed_phone` instead), when one of
those three `custom_data` strings is longer than 256 characters, when the source does not accept an
event's `event_type`, or when the source declares `valueCurrencies` and an
event with a `custom_data.value` has a missing or unlisted currency. It is
refused with `NOT_FOUND` when the source is not registered on that
advertiser. Resending an event in a later call is deduplicated by its
`event_type` and `event_id`. Set `testEventCode` to mark the call as test
traffic; the code is forwarded to the tracking service.

The response's `ingestion` object is the AdCP `log_event` result:
`events_received`, `events_processed`, and, when present,
`partial_failures`, `warnings`, and `match_quality`. Repeating a call with
the same `idempotencyKey` and the same events returns the first result with
`replayed: true`; the same key with different events is refused with
`CONFLICT`. Confirm events are arriving with
`get({ kind: "event_source", id: "EVENT_SOURCE_ID", advertiserId: "ADVERTISER_ID" })`.

On every buyer read, `sellerAccountId` is the seller's Account id; send it back to name that seller. The id of a seller's own object on its platform, such as a Meta dataset, a Snap Pixel, or a Meta Custom Audience ID, is a `platformId`, as on `sellerAccounts[].platformId` on an event source or an audience. Neither is an internal
customer ID. For one compatibility release, a seller's deprecated
`connection.customerId` remains available for existing integrations. Use
`sellerAccountId` instead; `connection.customerId` will be removed
in a follow-up release after this transition period.

For a saved Creative, add `include: ["preview"]` to `get`. The read returns a
15-minute render URL bound to the exact current Creative state revision, or a reason
when the format has no first-party renderable asset. The URL runs only on the
isolated Apostra render origin. A changed Creative invalidates the old URL,
and the response never includes storage URLs, provider preview HTML, or
measurement trackers. Direct image, video, and audio source assets are
renderable; active HTML, VAST, DAAST, and ZIP documents return a reason instead.

Before assembling uploaded assets, search the selected advertiser's visible
canonical format declarations. This read does not require a campaign and does
not make a provider call or setup request. Seller-owned product declarations
are available without a provider connection, including products a seller sells
through its own sales agent: those are read from the agent's published
catalogue, and an account-specific catalogue is never read. Direct-adapter
products keep their existing active-connection visibility rules:

```json theme={null}
{
  "kind": "creative_format",
  "filter": {
    "advertiserId": "ADVERTISER_ID",
    "productId": "PRODUCT_ID"
  }
}
```

`productId` is required to keep the catalogue read bounded. Add
`filter.sellerAccountId` or `formatKind` when the selected product supplies that scope.

Each result returns an opaque `id`, the seller's display name and id, the exact
`formatOptionRef`, its canonical `formatKind`, any declared frame and size, and
its slots. Every slot includes its stable `slotId`, required flag, minimum and
maximum cardinality, accepted asset kind, and the exact authored constraint
fields supplied by the product. Use the opaque result `id` with
`get({kind: "creative_format", id, advertiserId})` for stable readback. The
read includes only currently buyer-visible product and publisher declarations;
it does not add entries from the legacy global format catalogue. Pass the
returned `id` as `creativeFormatId` when calling advertiser-scoped
`save_creative`. The server revalidates the selected advertiser, seller,
product route, option, and declaration before reading an asset or writing a
Creative. If `formatKind` or `formatOptionRef` is also supplied, it must match
the selected format.

Search creative collections with exactly one campaign or advertiser scope. You can
create an advertiser collection without attaching it to a campaign. To read one,
use `get` with `kind: 'creative_collection'`, its id, and `advertiserId`. To change
an advertiser collection, use `save_creative_collection` with `advertiserId`,
`collectionId`, and the returned `updatedAt` as `expectedUpdatedAt`. Updates allow
name, description (`description: null` clears it), or `parentId` (`parentId:
null` clears it); member changes accept saved creative ids only. Results include
`parentId` and `descendantMemberCount`, which counts distinct members across the
collection and its active descendants. A parent must belong to the same
advertiser, cannot be the collection or one of its descendants, and cannot make
the hierarchy deeper than 16 levels. Archive with `isArchived: true` only after
archiving child collections. Restore with `isArchived: false` and the archive
response's `expectedUpdatedAt`; a collection with a parent can be restored only
while that parent is active and belongs to the same advertiser. A stale revision
does not change the collection, and `expectedUpdatedAt` is refused on campaign
collections, which carry no revision fence. Advertiser collections remain
non-executable: they do not attach campaigns or set roles, sync policy, or
arbitrary metadata.

Campaign collection membership uses `addMembers` and `removeMembers`. Each member
names its `memberType` (`creative` or `asset`) and `memberId`; added members may
also have a collection-specific `label`. Asset members are existing raw, source,
brand, or generated assets, addressed by id only. The tool never accepts asset
bytes, storage locations, provider identity, or signed URLs.

`addMemberIds` and `removeMemberIds` remain deprecated aliases for saved creative
members. Existing calls continue to work, with a limit of 500 IDs per field, but
do not send either alias with `addMembers` or `removeMembers` in the same call.

### Naming a seller by Account id

A seller is named by `sellerAccountId`, the seller's Account id: the same kind
of number as `buyerAccountId` and `accountId`. It is a different number from a
Storefront id. The same digits can name two different sellers in the two
spaces, so never copy a Storefront id into an Account-id input.

**Send `sellerAccountId` and `buyerAccountId`.** Every V3 response that names a
seller carries `sellerAccountId`, and every response that names the buyer's
Account carries `buyerAccountId`. That covers seller and connection reads, media
buys, proposals, creative formats, wholesale products, `request_proposals`
outcomes, `get_status` ready destinations and the seller's own `seller` object
and listing. A list carries `sellerAccountIds` (for example
`skippedSellerAccountIds` on `request_proposals`, and `appliedCohort.sellerAccountIds`).
Read the field there and send it back in the inputs below. An object's own `id`
is its Storefront id, and every field that refers to another seller, buyer or
creative engine uses that party's Account id. Responses no longer carry
`sellerId`, `storefrontId` or `buyerCustomerId`, with two exceptions described
below: a Seller's own `seller` object and listing keep `sellerId`, holding the
Account id, and a Demo Storefront keeps its own `storefrontId`. A call that
still sends an old input name is refused with a `VALIDATION_ERROR` that names
the replacement.

| Tool | Input |
| - | - |
| `request_proposals` | `sellerAccountIds`, `expectedSellerAccountId` |
| `save_media_buy` (create) | `sellerAccountId` |
| `save_campaign` | `sellerAccountIds` |
| `save_connection` | `sellerAccountId`, or `target: {kind: "seller", sellerAccountId}` |
| `open_media_partners_page` | `sellerAccountId` |
| `search`, kind `seller` | `filter.sellerAccountIds` |
| `search`, kinds `proposal` and `creative_format` | `filter.sellerAccountId` |
| `save_connection`, kind `creative_engine` | `target: {kind: "creative_engine", engineAccountId}` |
| `open_creative_engines_page` | `engineAccountId` |
| `save_creative_session`, a draft's `engine` | `engine.engineAccountId` |
| `open_approvals`, `open_media_buy_timeline`, `open_media_buy_retry` | `buyerAccountId` |
| `get` (media buy and seller account), `search` (a seller's media buys), `get_delivery`, `get_seller_reporting_receipts`, `request_seller_reporting_update` | `buyerAccountId` filter or input, the same number `buyerCustomerId` held |

* A Storefront id is a different number from the seller's Account id, and the
  same digits can name two different sellers. Never copy one into the other.
* The one seller `id` that is still a Storefront id is the `id` of a seller
  object a Buyer reads with `search` or `get` (`kind: "seller"`). It is what
  `get({"kind":"seller","id"})` takes, and `sellerAccountId` sits beside it. A
  media buy, proposal or product row carries only `sellerAccountId`. To reach
  that seller's object, call
  `search({"kind":"seller","filter":{"sellerAccountIds":[...]}})` and pass the
  returned seller's `id` to `get`. A Seller reading its own `seller` object or
  listing gets its Account id in `id` and `sellerId`, with `sellerAccountId`
  beside them.
* A Demo Storefront (`mode: "DEMO"`) keeps its `storefrontId`: it is the demo's
  own id, and a reset gives the demo a new one under the same Account.
* The `sellerAccountId` on a media buy's operation destinations
  (`operations.byDestination[]`) is the seller's Account id. The
  `sellerAccountId` of the Media Buys Page filter is different: it is the id of a
  seller-account mapping, not a seller's Account id. Send that filter as
  `sellerAccountMappingId`, its accurate name; `sellerAccountId` there is
  refused with an error that names it. A campaign read carries `sellerAccountIds`,
  the Account ids of its sellers, and no longer the `platformId` slug list
  `sellerIds`; write a campaign's sellers with `sellerAccountIds`.
* An Account id with no live seller is refused with a `VALIDATION_ERROR` that
  names the field and the id, before anything is read or written.
* A creative engine is named by `engineAccountId`, its Account id, in
  `save_connection` (`target: {kind: "creative_engine", engineAccountId}`), in
  `open_creative_engines_page` and in a `save_creative_session` draft's `engine`.
  The old inputs, `target.id` for a creative engine and `engineId`, are refused
  with a `VALIDATION_ERROR` that names the replacement. `engineAccountId` is not
  the engine's `id` or `target.id`: they are different numbers. Every response that
  names an engine carries `engineAccountId`: engines from `search` and `get`
  (`kind: "creative_engine"`) keep their own `id` and carry `engineAccountId`
  beside it, while every reference to an engine, which is the `target` and
  `engine` of a creative engine connection, a Creative Session's `engine` and
  the result of `open_creative_engines_page`, names it by `engineAccountId`
  alone. A connection's `target` of kind `seller` likewise carries
  `sellerAccountId` and no `id`; a billing target keeps its `id`. An Account id that owns no live
  creative engine, or that owns a seller, is refused with a `VALIDATION_ERROR`
  that names the field and the id. Read the id from the engine; never copy an
  engine `id` into `engineAccountId`.
* The other `open_*_page` tools are unchanged.

### Replay an uncertain advertiser create

When creating an advertiser, you may supply an `idempotencyKey` of 16–255
letters, numbers, underscores, periods, colons, or hyphens. Use one key for one
logical create and reuse that exact key with the unchanged create payload if the
response is lost or uncertain. The replay returns the originally created
advertiser; changing the payload with the same key returns a conflict. The key
is create-only and optional, so a create without it retains ordinary create
behavior. Keep `correlationId` unique for each transport attempt: it is a
diagnostic handle, not the replay key.

### Create and verify a campaign creative

Call `save_creative` with a `campaignId`, `name`, and optional `assets` to add a
creative directly to a campaign. Each asset supplies exactly one public `url`
or base64 `dataUrl`, plus its `assetType`. The direct tool accepts image, video,
audio, HTML, VAST XML, and text media; ZIP bundles continue through the inspected
[HTML5 upload flow](https://docs.apostra.com/v2/object-guides/creative.md#html5-bundles). Add `landing_page_url`
when the creative has a click-through destination. `makePrimary` is available
for image, video, audio, HTML, and VAST assets; text can be attached only as a
subsidiary asset. A VAST asset, whether a tag URL or inline XML, also takes
`vastVersion` (`2.0`, `3.0`, `4.0`, `4.1`, `4.2`, or `4.3`). A declared version
is kept even when the tag returns another, and the response's `warnings` say
so. Without one, Apostra uses the version it reads from the tag when it saves
it; a managed ad server cannot take a tag with neither. The creative read
returns the stored version as `assets[].vastVersion`. See
[Declare a VAST tag's version](https://docs.apostra.com/v2/concepts/video-creatives.md#declare-a-vast-tags-version).

For a local JPEG, PNG, or MP4, select the owning `advertiserId` and call
`upload_creative_asset`. A destination Storefront connection and provider
account are deliberately not part of this V3 source identity.
The Task hashes the file in the browser and sends the bytes directly to private
storage. File bytes never enter model or chat context. The signed PUT URL is
returned only to the embedded Task app; it is excluded from model-visible
results, chat/activity, logs, and persistence.

Configured buyer ingress accepts JPEG and PNG files of 1–30,000,000 bytes.
When managed-video ingress and inspection are configured, authenticated Buyer
Accounts also accept MP4 files of 1–2,000,000,000 bytes.
Finalization verifies the declared MIME type, exact size, and digest. MP4
finalization also reports asynchronous media inspection state. An expired
preparation must be started again; retrying a completed upload returns the same
source identity.

Pass a finalized JPEG/PNG source reference verbatim as `sourceAssetRef` to
`save_creative`. The advertiser is the supplied `advertiserId`, or is derived
from `campaignId`.

For an eligible reference, use `sourceAssetRef` instead of `assets`:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "name": "Autumn hero",
  "sourceAssetRef": "scope3-asset://v1/123e4567-e89b-42d3-a456-426614174000",
  "landing_page_url": "https://example.com/autumn"
}
```

The server accepts only the customer, authenticated principal, and advertiser
that owned the finalized upload. It re-verifies the private bytes, copies them
into the governed creative asset store, and saves only the durable asset URL
plus a one-way source fingerprint. Private object paths, source references,
and signed URLs are never persisted on the creative.

With `campaignId`, the creative is created under that campaign's advertiser
first and then attached through normal campaign membership. Retry the same
`sourceAssetRef` and `name` after an uncertain response: an existing adoption
is returned and any interrupted campaign attachment is completed. A different
name is refused rather than renaming or duplicating the adoption. Use
`advertiserId` instead of `campaignId` to keep the creative in the advertiser
library for later assignment.

To assemble uploaded assets before a campaign exists, search the selected
product with `search({kind: "creative_format", filter: {advertiserId,
productId}})`, then pass the result's opaque `id` as `creativeFormatId`. Bind
each durable asset to a declared slot through `sourceAssets[].assetId`. The
upload Task does not choose a format. `save_creative` derives the canonical
`formatKind` and `formatOptionRef` from the signed selection and resolves only
its exact seller and product route.

Use `advertiserId` with `creativeFormatId` to keep the assembled Creative in
the Library without a campaign. For a campaign-scoped create, use `campaignId`,
`formatKind`, and the campaign product's exact `formatOptionRef` instead; the
save also attaches the new Creative to that campaign. V3 does not yet attach an
existing advertiser-only Creative; use the V2
`attach_creatives_to_campaign` operation for that later step. Ordinary
`assets` are unbound and
cannot satisfy those slots in the same call. Repeatable groups use
`group[index].member`: bind uploaded media through `sourceAssets[].slot` and
text or URL members through `social.components[].slot`, using the same
zero-based index for every required member in an instance. Indices must be
contiguous from zero. For example, a hosted-video format can bind an MP4 to
`video` and a JPEG or PNG to `thumbnail`:

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "name": "Autumn video",
  "creativeFormatId": "SIGNED_CREATIVE_FORMAT_ID",
  "sourceAssets": [
    {
      "assetId": "123e4567-e89b-42d3-a456-426614174000",
      "slot": "video",
      "makePrimary": true
    },
    {
      "assetId": "223e4567-e89b-42d3-a456-426614174000",
      "slot": "thumbnail"
    }
  ]
}
```

To save an incomplete assembly, add `"mode": "draft"`. A draft is owned by its
advertiser, like every Creative, and can omit required media, copy,
destination, or repeat-group slots. A draft that binds no files yet needs
`creativeFormatId`, so you can save it before any slot is filled and bind files
with `sourceAssets` on a later save. A draft selected with `formatKind` (and
optional `formatParams`) binds its files in the save that creates it, because
only a `creativeFormatId` draft can be revised. The response and every later Creative read return its
`state` and whole-state `stateRevision`. The existing opaque `revision` remains
the buyer-preview projection digest.

Create a draft with `advertiserId` or with `campaignId`. A `campaignId` resolves
the owning advertiser and records the campaign the Creative is meant for. If you
send both, they must name the same advertiser. A draft never attaches,
materializes, reviews, syncs, or delivers: it is not on the campaign, its media
buys, or any seller until a `"mode": "complete"` save succeeds on the same
`creativeId`. Only a new draft takes `campaignId`; revisions keep the campaign
the draft was created with.

Revise the same draft by sending its `creativeId`, current `stateRevision` as
`expectedRevision`, the signed `creativeFormatId`, and the complete replacement
`sourceAssets` and authored-slot state. The save replaces the prior submitted
state; it never merges two revisions. A stale revision fails without changing
the Creative.
Use `"mode": "complete"` on that same replacement when all required slots are
present. Completion runs the format's current server-side validation. A draft
that was not created with a `campaignId` stays in the advertiser Library
without attaching to a campaign or contacting a provider. A draft that was
created with a `campaignId` joins that campaign when completion succeeds,
exactly as a Creative saved under `campaignId` does, and is sent to the media
buys on it whose products accept its format. A completing save does not take a
`campaignId` of its own. If the Creative is complete but cannot join the
campaign, the response still returns the saved Creative with a warning naming
the campaign and the error code. Attach it afterwards with `creativeId`,
`advertiserId`, and `campaignId`, or `campaignIds`, as described below.
Existing calls that omit `mode` keep complete-save behavior.

The server re-proves every source against the advertiser. A video must have
completed inspection and reached `promoted`. Saving under `advertiserId`
creates the canonical Creative without a campaign or provider call. Saving
under `campaignId` also attaches it. An attached Creative built from Library
video or audio is added to every media buy on the campaign whose product
accepts its format, like any other Creative; sellers receive a link to the
media and fetch it once. Do not substitute the provider-bound V2 adoption
operation for a buyer-owned V3 source.

After the campaign executes, call `get` with `kind: "creative"`, `sourceId` set
to the campaign ID, and `id` set to the returned creative ID. When a legacy
Sales Agent library receives the creative through `sync_creatives`,
`platformLinks` rows show the
downstream ad-platform sync status, platform ID, approval status, and last sync
time. Inline package delivery does not create a `platformLinks` row. Each agent
has one authoritative row: the most recently updated account lineage wins
after a credential or account rotation. A platform ID confirms that the
destination created its creative object; the status and approval fields still
determine whether it is ready to deliver. When a row is `not_sent` or `failed`,
`failureReason` says why and, with it, who must act, and
`sellerErrors` carries the structured part of what the seller named (never its
free text); see [Creative sync outcomes](https://docs.apostra.com/v3/creative-sync-outcomes.md). Existing metadata-only
`save_creative` calls require no changes.

Start with `get_status`. Its `operatorIdentity.usableForBuying` field is false
when the operator is missing or still uses a platform-owned legacy fallback.
In that state, an account administrator must call `save_buyer_operator` with
the buyer's real non-platform domain before new discovery or buying work; the
account's operating-unit code is generated from its name, or pass a short one
as `operator_unit`. If the identity is locked, follow the support action from
`get_status` instead, unless the account has no unit yet (`operator_unit` is
null), which the same call can still assign. This confirms the
commercial operator identity used with sellers; it does not add users, change
account membership, or change the login organization.

### See how a saved creative's trackers expand

When the creative you save has trackers, the `save_creative` result for a
create or an update carries `macroPreview`, and the result text summarizes it.
It is a dry run of Apostra's macro compiler on the saved creative's impression
and click trackers (a click tracker includes the creative's own
`macroAdditions`). It never fetches a URL and never uses a real identifier. A
creative with no trackers has no `macroPreview`.

| Field | Meaning |
| - | - |
| `trackers[].tracker` | `impression_tracker` or `click_tracker` |
| `trackers[].given` | The tracker URL as saved |
| `trackers[].compiledUrl` | The URL compiled to AdCP macro syntax |
| `trackers[].publishable` | `false` when the compiler refused the URL; `compiledUrl` is then the URL unchanged |
| `trackers[].macros` | The AdCP macros in the compiled URL, without braces |
| `trackers[].filledBySeller` | Macros every seller that declares the format fills |
| `trackers[].filledBySomeSellers` | Macros some of those sellers fill and others do not, so whether they expand depends on the seller |
| `trackers[].filledByApostra` | Identity macros Apostra fills when it syncs the creative: `MEDIA_BUY_ID`, `CREATIVE_ID`, `PACKAGE_ID` |
| `trackers[].literal` | Macros no declaring seller or Apostra fills, so they reach the impression as literal text |
| `fillSet` | The format option the seller declarations came from (`formatOptionId`) and how many sellers declare it (`sellerCount`). `filledBySeller`, `filledBySomeSellers` and `literal` are worked out across those sellers |
| `fillSetUnknownReason` | Set instead of `fillSet` when which macros a seller fills is not known: `not_tied_to_format`, `format_not_found` or `format_lookup_failed`. The three seller lists are then absent, because Apostra does not guess |
| `unavailableReason` | Present when the dry run failed. The creative was still saved, and `trackers` is empty |

To find which macros a seller resolves, read the creative format with
`get({ kind: "creative_format", id, advertiserId })`. Its
`macro_resolution_capabilities` lists, for each macro, who fills it and in which
context; an absent entry means that format does not fill the macro. A creative
tied to a format option (`formatOptionRef`) gets its fill set from those
declarations. The full macro list is AdCP's universal macros.

### Launch a campaign with explicit confirmation

Launching with `save_campaign` is a two-call operation. First request a
preview without changing the campaign:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "desiredPhase": "active",
  "idempotencyKey": "campaign-launch-preview-v1"
}
```

This returns `action: "pending_confirmation"` — a successful, structured
response, not an error — with `campaign.revision`, the campaign budget and
flight, and under `launch` exactly what would go live: `mediaBuys` (each
staged media buy's id, name, phase, budget, and seller; the first 25, with
`mediaBuysTruncated: true` if there are more), `mediaBuyCount`, and
`combinedBudget` (the sum across those buys when they share a currency;
`null` with a per-currency `budgetsByCurrency` list when they do not).
`nextStep` carries the exact follow-up call, including a fresh `idempotencyKey` for the confirmation (the preview's key must not be reused, or the preview is replayed). It does not launch the campaign.
Do not combine this preview with campaign field changes; save those changes in
a separate call before requesting the preview.

After reviewing the preview, send that `campaign.revision` as
`expectedRevision` with explicit confirmation:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "desiredPhase": "active",
  "confirmLaunch": true,
  "expectedRevision": 7,
  "idempotencyKey": "campaign-launch-confirm-v1"
}
```

`confirmLaunch: true` requires the `expectedRevision` returned by the preview.
The server checks that revision again while holding the campaign mutation lock,
before any provider dispatch. If the campaign changed after the preview, the
call returns `REVISION_CONFLICT`; re-read the campaign, request a new preview,
and confirm that new revision instead of reusing the earlier confirmation.

If an earlier launch booked media buys with their sellers and then stopped
part-way, the campaign is still a draft but no buy is staged any more. The
preview then lists the booked buys instead of reporting none, and each entry
carries `willStart`: `true` for a paused buy that confirming resumes, `false`
for a buy that stays as it is (still awaiting seller approval, already
delivering, or paused on purpose by a buyer). Confirming resumes the paused
buys that were not paused on purpose and moves the campaign out of draft. A
buy a buyer paused on purpose stays paused; resume it with
`save_media_buy({ mediaBuyId, isPaused: false })`. If a seller refuses a
resume, for example while an ad set is still in review, the response lists that
buy under `execution.errors` with the seller's wording, the campaign stays a
draft, and confirming again retries only the buys that are still paused.

If the campaign still has staged buys as well, confirming sends them as usual
and also resumes the booked buys that are waiting for launch; the preview lists
both, with `willStart` on the booked ones. The staged buys decide whether the
campaign goes live. A booked buy whose resume a seller refuses is listed under
`execution.errors`; once the campaign is live, resume it with
`save_media_buy({ mediaBuyId, isPaused: false })`.

If a seller rejects a media buy during confirmation, the launch response keeps
the top-level launch failure code and includes the seller's bounded reason as
fenced data in the affected buy's execution detail. Read that media buy with
`get(kind: "media_buy")` for the same bounded, neutralized reason in
`errorMessage`; its text rendering is fenced as seller-provided data.

### Cancel a campaign and its media buys

To cancel a campaign, send `desiredPhase: "canceled"` by itself with the
campaign's current `expectedRevision`:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "desiredPhase": "canceled",
  "expectedRevision": 7,
  "idempotencyKey": "campaign-cancel-v1"
}
```

This is irreversible: it cancels the campaign and every live media buy through
its existing seller path. A guaranteed committed buy goes to operator approval
and keeps delivering until approved. It is asynchronous: a requested cancellation can return per-buy
`canceled`, `cancel_pending`, and `cancel_failed` outcomes; a buy already
terminal retains its observed terminal phase. While any buy is pending or
failed, the campaign reads as `phase: "ending"` with reason
`"canceled"`; the response plainly says it is not canceled until every buy is
confirmed. Pending exposure stays visible and releases only when that buy
becomes terminal. The background status sync moves the parent to
`phase: "canceled"` after the final child settles, without another buyer call.
Retry with the latest `expectedRevision` to re-drive pending or failed buys;
confirmed buys are not canceled again.
To stop one buy without affecting its campaign or siblings, call
`save_media_buy` with `isCanceled: true`; use `isPaused: true` to pause it
instead.
Archive is separate: use `isArchived: true` only when you want to remove a
campaign from default visibility.

### Archive and restore

`save_advertiser` and `save_campaign` archive with `isArchived: true` and
restore with `isArchived: false`. Archiving is a soft delete: the record moves
to archived state and everything under it is retained and stays readable.
Archiving an advertiser does not lock its campaigns, media buys, or creatives
— `search` and `get` on those children keep working — and it frees the
advertiser's name for a new advertiser. Restoring an advertiser whose name is
now held by an active advertiser returns `CONFLICT`; rename or archive the
other advertiser first.

To archive several advertisers in one confirmed action, pass `advertiserIds`
(up to 50) with `isArchived` instead of `advertiserId`. Each advertiser is
archived or restored independently and the response lists every outcome under
`results`; `partialWrite: true` marks a mixed result.

Archiving a campaign changes only its visibility. It is refused while any of
the campaign's media buys is a draft, live, or otherwise not finished; the
error lists each one. A restored campaign keeps the phase it had when it was
archived, and its media buys are unchanged. A campaign deleted through the v2
REST `DELETE` cannot be restored; create a new one. `save_media_buy`
archives a **draft** media buy only (`isArchived: true`); a dispatched buy
cannot be archived. Request its cancellation with
`save_media_buy({ mediaBuyId, isCanceled: true })`, then archive it once its
cancellation is terminal. That cancellation may wait on seller or operator
approval and must not be reported as done before it is. Read
`cancellationPending`, not `action`, to tell whether it is still waiting. An
archived media buy cannot be restored; stage a new one on the campaign. A
completed buy stays as the delivery record, cannot be archived or canceled,
and no longer delivers.

### Advertiser and campaign tracking

Tracking is a typed field on the existing `advertiser` and `campaign` nouns;
it is not a separate V3 noun or tool family. V3 uses the same tracking services
as the V2 REST compatibility surface.

Set advertiser defaults with `save_advertiser`. This HappyDemics example keeps
the raw vendor tag, identifies its documented dialect, and lets the server
compile it to AdCP universal macros:

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "tracking": {
    "enabledMacros": ["DEVICE_ID", "GDPR_CONSENT"],
    "trackers": [
      {
        "name": "HappyDemics exposure",
        "vendorName": "HappyDemics",
        "url": "https://c.ahdmw.com/pxl/EXPOSURE_ID?gdpr_consent={{GDPR_CONSENT_STRING}}&udid={{USER_ID}}",
        "trackerType": "impression",
        "sourceDialect": "happydemics"
      }
    ]
  }
}
```

Read it back with:

```json theme={null}
{
  "kind": "advertiser",
  "id": "ADVERTISER_ID",
  "include": ["tracking"]
}
```

The advertiser's `tracking.trackers[]` returns a stable `trackerId`, the
`url` as you saved it, the translated `canonicalUrl`, and the auditable mapping
metadata. In the example, documented HappyDemics tokens canonicalize as
`{{USER_ID}}` → `{DEVICE_ID}` and `{{GDPR_CONSENT_STRING}}` →
`{GDPR_CONSENT}`. Other double-brace spellings are not inferred without their
own vendor evidence.
V3 returns bounded mapping, diagnostic, and documentation summaries with their
total counts; the V2 tracking-config response retains the complete stored
mapping artifact for non-model audit clients. Long URL values use bounded
previews with their original length and a truncation flag, and effective
campaign entries do not duplicate the source tracker's mapping artifact. When
updating an existing tracker, send its `trackerId` and omit `url` to preserve
the complete stored URL; a returned truncated preview is also recognized and
will not overwrite that stored value. Send the returned advertiser `revision`
or campaign `campaignRevision` as `tracking.expectedRevision` to make any
tracking update an atomic compare-and-set; URL-preserving updates add that
guard automatically.

Tracker URLs are limited to 8,192 characters and 64 macro tokens. For a custom
event, set `trackerType` to `custom` and supply `customEventName`; that field is
invalid for impression or click trackers. Stored free-form tracking values
returned by V3 are bounded and fenced as untrusted external data. Tracker
labels, custom event names, and custom-macro descriptions can be passed back
unchanged on an update; the server restores their exact stored values. New or
changed custom-macro descriptions are limited to 240 characters.

Before saving or trafficking a tracker, call `test_creative_macros`. The tool
does not fetch the URL or change a creative. Standard request auditing retains
only statuses and counts, never tracker URLs or binding values. The tool
returns four independently
copyable stages—exact raw input, canonical AdCP form, recipient form, and final
synthetic substitution—plus mapping evidence, bindings, and diagnostics.

```json theme={null}
{
  "rawInput": "https://c.ahdmw.com/pxl/EXPOSURE_ID?udid={{USER_ID}}",
  "sourceDialect": "vendor",
  "sourceDialectNamespace": "https://support.happydemics.com/launch/exposure-data/how-to-add-my-exposure-data/pixel-retargeting",
  "targetDialect": "gam",
  "scenario": "device_id_present",
  "scenarioKey": "review-1"
}
```

For this example, the stages show `{{USER_ID}}` → `{DEVICE_ID}` →
`%%ADVERTISING_IDENTIFIER_PLAIN%%`, while the final stage uses a visibly
synthetic advertising identifier.

`sourceDialect` uses AdCP's macro dialect names: `adcp`, `iab_vast` or
`vendor`. Leave it out and Apostra detects the dialect from the URL's origin.
AdCP's `iab_daast` and `unknown` are refused, because Apostra holds no DAAST
mapping and `unknown` describes a token rather than declaring a source. A
`vendor` names its macro reference in `sourceDialectNamespace`, and may name an
earlier or the current revision of that reference in `sourceDialectRevision`.
Apostra applies its current mapping and the report returns the revision it
applied; a later revision than Apostra has checked is refused. `iab_vast` needs
no namespace. A vendor namespace Apostra has no mapping for needs a
`sourceDialectRevision`; its tokens stay unresolved.

| `sourceDialect` | `sourceDialectNamespace` | Revision Apostra has checked |
| - | - | - |
| `iab_vast` | `https://github.com/InteractiveAdvertisingBureau/vast/blob/master/vast4macros/data/macros-data.json` | `2026-08-21` |
| `vendor` (Google Ad Manager) | `https://support.google.com/admanager/answer/2376981` | `2026-10-07` |
| `vendor` (Campaign Manager 360) | `https://support.google.com/campaignmanager/answer/2826636` | `2026-10-14` |
| `vendor` (HappyDemics) | `https://support.happydemics.com/launch/exposure-data/how-to-add-my-exposure-data/pixel-retargeting` | `2026-08-21` |

Each entry of `mappings` describes one token with AdCP's field names:
`token`, `dialect`, `dialect_namespace`, `dialect_revision`, `dialect_semantic`,
`mapping_status` and `universal_semantic`, plus Apostra's own `start` and `end`
offsets and `registryVersion`. `mapping_status` is `verified_universal` when
the token maps to an AdCP universal macro, which `universal_semantic` then
names, and `unresolved` otherwise. A token that is not a universal macro but is
spelled in AdCP syntax, such as `{NOPE}`, has the dialect `unknown`.
`dialect_semantic` is the dialect's own name for the token and is left out when
Apostra has none recorded.

For consent syntax inspection, `gvlVendorId` can show the corresponding GAM
`${GDPR_CONSENT_<id>}` form, but a caller-supplied number is not verification:
the report remains failed and not preview-ready. Without the number, the
consent parameter is removed and the report also fails closed. A production
consent mapping needs separate evidence that the ID belongs to the measurement
vendor; the tester never infers that relationship from a pixel URL.

Available scenarios are `device_id_present`, `device_id_unavailable`,
`gdpr_applies_with_consent`, `gdpr_does_not_apply`, and
`missing_required_value`. The unavailable-device scenario deliberately shows
an empty device value and labels it `privacy_suppressed`. Change `scenarioKey`
to obtain a different deterministic cachebuster. Explicit `macro_values`, a map from an
AdCP universal macro name without braces (such as `DEVICE_ID`) to its value, must be raw
synthetic test values, never real user identifiers and never pre-encoded; a
value that already contains percent-encoded octets is flagged as possible
double encoding. Binding values and stage templates in the report are bounded
by UTF-8 bytes, and `projection` reports any truncation or omitted detail. The
whole Apostra result stays within a 96 KiB budget. Inspect the substituted stage
for placement and encoding. Explicitly required macros that are absent, missing
required values, unknown or wrong-case tokens, ambiguous vendor syntax, unsafe
nesting, and missing recipient mappings keep `passed` and `readyForPreview`
false.

Campaigns inherit advertiser trackers dynamically. Add a campaign-local
tracker or disable one inherited default with `save_campaign`:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "tracking": {
    "trackers": [
      {
        "name": "Campaign measurement",
        "url": "https://measurement.example/pixel?cb={CACHEBUSTER}",
        "trackerType": "impression"
      }
    ],
    "overrides": [
      {
        "trackerId": "trk_0123456789abcdef01234567",
        "enabled": false
      }
    ]
  }
}
```

Request `include: ["tracking"]` when reading a campaign. Its `tracking` field
contains the local `trackers`, inherited `overrides`, and `effectiveTrackers`
with `sourceScope`, `inherited`, `effectiveEnabled`, and `statusReason`.
Within `tracking`, each supplied array is the complete desired array for that
scope; omit an array to leave that part unchanged.

## Seller tools

| Tool | Purpose |
| - | - |
| `save_seller` | Patch Seller identity, setup intent, declared capabilities, and the listing (description, coverage, subtitle, and website, support, privacy and terms links), or hold and resume new business with `isPaused` ([intake hold](https://docs.apostra.com/v3/seller-intake-hold.md)) |
| `save_agent` | Confirm an owned Sales Agent's product mode, or correct the mode only for the Seller's own Source binding to an unclaimed Sales Agent. An owner confirmation rejects `sourceId`. A seller may omit `sourceId` when one binding matches. If several Sources use the Agent, the refusal lists the candidate Source and connection IDs; repeat with one `sourceId`. Every write requires a reason. |
| `save_inventory_source` | Create or update an inventory source, including one existing modular source module's non-secret configuration, pass only `agentId` to connect an own or partner sales agent, or pass only `adServer` to open the form that connects an ad server ([Connecting an ad server](https://docs.apostra.com/v3/seller-connect-ad-server.md)), without model-visible credentials |
| `save_material` | Register immutable seller source revisions from URLs, uploads, manifests, inline text, or history; archive or restore a Material with `isArchived`; reprocess lossless renditions with `reprocess`; update metadata, including the seller-governed Library `documentPurpose`; or decide Material-owned candidates with `candidateDecisions`. One call declares one change, so there is no `action` to choose, and a call that sends one of the old `action` values is refused with a validation error. A Material with no stored `documentPurpose` is returned as `uncategorized`; when updating metadata, omit the field to retain the existing category or set it to `null` to clear it. The closed purpose set does not include rate cards or pricing. `unitReusable` sets non-priced slides, pages, or sheets reusable by `unitId`; `UNIT_NOT_REUSABLE_KIND` rejects a document container and `UNIT_CONTAINS_PRICING` rejects a unit with commercial figures. This field is gated by the `sales-library` rollout. A rate card or avails sheet is previewed with `get` on the Material and the `rate_card_preview` or `avails_sheet_preview` include, then applied with `commitPreview`. |
| `save_rfp` | Create an RFP (including `imported` origin), revise it with a new request on `parentTurnId`, endorse its response with `endorsed: true` and optional `endorsementCommentary`, remove the endorsement with `endorsed: false`, or attach an uploaded or historical-proposal response with `responseMaterialId`. Response-pair writes are gated by the `sales-library` rollout. A turn's feedback, release, proposal files and outcome use `save_rfp_turn`. |
| `save_rfp_turn` | Change one RFP turn, named by `rfpId` and `turnId`, with exactly one of: `released: true` to release it; `representation` to request, or `cancelRepresentationId` to cancel, a JSON, HTML, PDF or PowerPoint proposal file; `feedback` (a list of `{ key, value }` entries, with optional typed `grade`, `ledBy` and `commentary`) to append feedback; or `outcome` (its `details` is a list of `{ key, value }` entries) to record the commercial result. Feedback and outcome are append-only events and a proposal file starts rendering work. Typed feedback is gated by the `sales-library` rollout. |
| `save_library_request` | Gated by the `sales-library` rollout. `open` creates or returns the one open request for the exact gap, optionally naming its origin RFP turn. `close` records uploaded or dictated seller Material, named by `closingMaterialId` as every read returns it, as the closing evidence. |
| `save_coverage` | Add or remove declared publisher domains and property claims |
| `save_wholesale_product` | Create or update a wholesale product |
| `save_media_kit` | Deprecated compatibility write for the legacy Business Profile; new clients author the buyer-visible listing through `save_seller`'s `listing` field (`mediaKit` is a deprecated alias for the same field) |
| `save_playbook` | Save selling guidance and house-discount declarations |
| `save_business_rules` | Save acceptance and operating rules |
| `save_advertiser_instructions` | Save instructions scoped to a buyer or advertiser relationship |
| `save_signal` | Create or update a seller signal |
| `save_work_item` | Complete or update a typed item from the unified work queue |
| `get_delivery` | Query seller delivery or cumulative margin; integrated Media Companies can also select Buyer campaign delivery |
| `get_rfp_performance` | Query aggregate, privacy-protected RFP quality, efficiency, and commercial metrics |

Seller `search` and `get` kinds include the Seller singleton, inventory sources,
material, RFPs and RFP turns, library requests, coverage, wholesale products, playbook, business rules, media buys,
work items, signals, asks, organization-owned Agents, and other kinds advertised by the live schema.
`get({"kind":"advertiser_access","advertiserId":"..."})` reads who has client
access to one advertiser and the actions you can take now; see
[Read client access for an advertiser](https://docs.apostra.com/v2/buyer/advertisers/tasks/read-advertiser-client-access.md). Follow the
[Seller workflows](https://docs.apostra.com/v2/setup/v3/seller-workflows.md) page for sequencing.

`get({"kind":"seller","include":["readiness"]})` adds the setup checklist, the
go-live state and progress, starter-brief coverage, the wholesale product count
and source-health diagnoses. See
[Reading your seller's setup checklist and go-live state](https://docs.apostra.com/v3/seller-readiness.md).

`get({"kind":"seller","include":["operating"]})` adds whether buyers can use the
seller now, whether it is paused or archived, and how it is routed.
`include:["entitlements"]` adds what its plan includes. Both read the seller
account's own record; see
[Reading your seller's operating state and plan](https://docs.apostra.com/v3/seller-operating-state.md).

`get({"kind":"seller","include":["teaching"]})` adds the Product marketing
counts: `claimCount`, `gapClaimCount` and `lastVerdictChangeAt`. It is refused
with `ACCESS_DENIED` for a seller outside the Product marketing cohort. See
[Reading your seller's Product marketing counts](https://docs.apostra.com/v3/seller-teaching.md).

`listing`, `playbook`, `business_rules`, `coverage`, `distribution`, and
`notification_config` are one per account, so `get` reads them without an `id`.
If a call supplies an `id` for one of these kinds anyway, `get` ignores it,
returns the account's own object, and adds a `warnings` entry saying the `id`
was ignored. `billing` still rejects an `id`, and in a Seller account
`get({"kind":"seller"})` must omit it.

### Signals

`save_signal` writes a signal and `get` / `search` (`kind: "signal"`) read it
back, using AdCP's own names. The `draft` takes one of two shapes, and its
`kind` field chooses which: `kind: "managed"` for a signal on an ad-server
source, or `kind: "catalog"` for a signal in the seller catalog.

* **Seller-catalog signal** (`kind: "catalog"`, no `sourceId`): a create needs
  `signalId` and `keyType`. `countries` lists the ISO 3166-1 alpha-2 countries
  where the signal may be used; leave it out for no restriction. You can change
  `name`, `description`, `countries`, `isLive` and access later; `keyType`,
  `metadata` and `agentId` are fixed at registration. Each access grant's
  `price` is an AdCP `VendorPricingOption` (`cpm`, `percent_of_media`,
  `flat_fee`, `per_unit` or `custom`).
* **Ad-server signal** (`kind: "managed"`, with `sourceId`): a write replaces
  the whole signal, so send `signalId`, `name`, `valueType` and `adapterConfig`
  plus every optional field you keep. Prices are AdCP `pricing_options`, with
  `pricing_option_id`, `max_cpm` and `unit_price` as AdCP names them. Set
  `targetingDimension` to `age` for an age signal or `property` for a property
  signal; any other dimension makes a generic signal. `save_signal` refuses
  `signalType` and `type`.
* **Settings as entries.** The ad-server mapping (`adapterConfig`) and a catalog
  signal's `metadata` are lists of `{ key, value }` entries, one per setting.
  `key` is the path to that setting: dotted names for nested objects
  (`owner.team`), a bracketed index for list items
  (`groups[0].criteria[0].keyId`), and a quoted name in brackets for a setting
  whose name holds a dot, a bracket or a space (`["a.b"]`). `value` is a string, a number, a boolean, a
  list of strings, `null`, or an empty object `{}`. For example, these entries:

  ```json theme={null}
  [{ "key": "kind", "value": "audience_segment" }, { "key": "segment_id", "value": "123" }]
  ```

  send `{ "kind": "audience_segment", "segment_id": "123" }`. A key listed twice, a key that is
  both a value and the parent of another, or a gap in a list is refused. A read
  returns these settings exactly as stored, as a nested object. A managed write
  replaces the whole mapping, so to edit one setting, send every setting you
  keep as an entry, including any stored as `null` or `{}`.
* **Price options are AdCP's.** Each price is an AdCP vendor pricing option
  with AdCP's field names and value rules: `cpm`, `percent_of_media`,
  `flat_fee`, `per_unit` or `custom`, each with a `pricing_option_id`. The
  published schema lists exactly those fields, so a model sees closed objects
  and no open map. It leaves out AdCP's `ext` field, and the
  `applies_to_output_format_ids` and `applies_to_output_capability_ids` fields,
  which AdCP defines for creative transformers and says signals omit. A price
  that carries `ext` or those fields is still accepted and stored as sent.
* **The older open `draft` still works for now.** A draft with no `kind`, an
  open object whose `adapterConfig` and `metadata` are nested objects, is
  still accepted for now. Send `kind` and entries instead: the open draft will
  be removed.

### Agent event subscriptions

A buyer agent in the V3 beta can use `save_notification_config` with an
`eventSubscription` change to create, update, or delete its own event
subscription. A subscription references a verified webhook endpoint by ID; it
never accepts a webhook URL or signing secret. Reporting subscriptions choose
either interim-and-final updates or final-only updates. Both always receive the
final update. Indicator subscriptions receive every update.

`get({"kind":"notification_config"})` returns the authenticated buyer
agent's event subscriptions alongside the account notification settings. It
does not reveal another agent's subscriptions or any endpoint credential.

`search({"kind":"proposal"})` lists one campaign's proposals and requires
`filter.campaignId`; `advertiserId` does not scope proposals.

Read a proposal's products with `get({"kind":"proposal","id":"PROPOSAL_ID",
"include":["products"]})`. A composed product can carry
`wholesaleProductIds`: the opaque `sf2:` routes for its active, buyer-visible
wholesale catalogue products. Use one of those routes with
`search({"kind":"wholesale_product"})` to recognise the matching pricing and
other catalogue details the buyer is allowed to read. The field is omitted when
no active buyer-visible catalogue product matches; it never exposes seller-local
identifiers or pricing by itself.

The proposal read's text also names each captured product and its pricing: a
fixed product shows its fixed price and currency, while an auction product
shows its floor, max-bid setting, and available p25, p50, and p75 guidance.

The `filter.status` enum lists every kind's statuses, some in two cases. When
a listed value differs from the requested kind's own status only in case,
`search` reads it as that status: `pending` on an inventory-source search reads
as `PENDING`, and `OPEN` on a library-request search reads as `open`. A value
the kind does not have in any case is still refused, so a work-item search
never treats a modular `OPEN` as an approval state.

### Inventory-source recorded health evidence

Read one source with `get({"kind":"inventory_source","id":"SOURCE_ID",
"include":["diagnostics"]})`. Its compact `health` projection preserves the
recorded `lastErrorAt`, `lastSuccessAt`, and `lastCheckedAt` values from source
diagnostics, including `null`. These are recorded observations, not a new
provider probe made by `get`: when the check timestamp is `null`, freshness is
unavailable rather than assumed to be now. A later recorded success makes an
earlier recorded error historical evidence; it does not erase the original
evidence or prove a transaction succeeded. A successful health observation is
not proof that a media buy was accepted or delivered. To request a new
no-spend check, use the existing `refresh_inventory_source_health` flow.

Every inventory source also carries `isAdapterSource`. Add
`include:["poweredByAgent"]` to read which Agent powers a source (`null`,
`{"state":"ACTIVE","agentId":"..."}` or `{"state":"REVOKED"}`), and
`include:["testRuns"]` for its latest five recorded source tests. See
[Reading an inventory source](https://docs.apostra.com/v3/seller-inventory-source-facts.md).

`search({"kind":"library_request"})` and
`get({"kind":"library_request","id":"LIBRARY_REQUEST_ID"})` are gated by
the `sales-library` rollout. Search accepts `filter.status` (`open` or
`closed`) and `filter.originRfpTurnId`; get returns the request's gap, status,
origin turn, and closing Material evidence.

### Organization-owned Agents

`search({"kind":"agent"})` lists the software Agents the active account's
organization owns. Add `query` to match an Agent ID, Agent display name, or
operator display name. This explicit-only kind does not appear in unrelated
broad object searches and does not turn a buyer counterparty Storefront into an
Agent.

Read one result with `get({"kind":"agent","id":"AGENT_ID"})`. Add
`include:["certification"]` for the complete customer-safe certification
summary, `include:["validationRuns"]` for up to five server-bound run summaries,
`include:["webhookDeliveries"]` for the agent's recent inbound webhook outcomes with each
refusal's reason ([details](https://docs.apostra.com/v3/agent-webhook-deliveries.md)), or `include:["diagnostics"]` for
observed implementation health and the current production-revision label. A returned run's `diagnosticRead` shows the exact
follow-up: pass its `validationRunId` with `include:["diagnostics"]` to receive
the bounded Activity graph. Sales Agent detail also names its immutable
versioned public `validationSkill`. These reads intentionally omit
credentials, private evidence, operator identifiers, relationship identities,
and owner-only action URLs.

### Sales agents in a Seller account

In a Seller account, `search({"kind":"agent"})` lists the sales agents the
seller can add, and each result's `relationship` says which kind it is:

* `own`: a sales agent owned by any account in the seller's organization (a
  parent and its child accounts count as one organization), for example one
  registered in the organization's developer account.
* `partner`: a certified partner's live sales agent owned by another
  organization, with a current published connection setup that offers an
  authentication method, once the partner may operate agents for other
  organizations' clients through the Partner Program.

Each result carries:

| Field | Meaning |
| - | - |
| `relationship` | `own` or `partner`. |
| `agentId` | The agent's ID. |
| `displayName` | The agent's name. |
| `partnerName` | Partner rows only. The partner that runs it. |
| `urlMode` | Partner rows only. `shared` when the partner fixes one serving URL and the login tells it which publisher is calling. `per_tenant` when the publisher fills in its own URL. |
| `signupUrl` | Partner rows only, and optional. Where a publisher with no credentials signs up with the partner. Absent when the partner published none. |

The page also carries `developerAccounts`, the organization's developer
accounts as `accountId` and `name` (empty when it has none), and
`developerAccountsUrl`, the account settings screen that creates one. See
[Connect to a partner agent](https://docs.apostra.com/v3/seller-partner-connection-form.md#the-agents-a-seller-account-can-add).

Add `query` to match an agent name, partner name or agent ID. A page holds at
most 50 agents; pass the returned `nextCursor` as `cursor` for the next page.
`get({"kind":"agent","id":"<agentId>"})` reads any listed agent and returns the
same fields. An agent the Seller account itself owns keeps its full read, where
`include` and `validationRunId` apply. An ID that names no agent the Seller can
add is not found.

`save_inventory_source` with only an agent's `agentId` connects it: an own
agent opens the connection task bound to it, and a partner agent opens the
partner connection form. A Seller account cannot create a sales agent from an
`endpointUrl` alone; that create is refused with `VALIDATION_ERROR`. See
[Connect to a partner agent](https://docs.apostra.com/v3/seller-partner-connection-form.md#a-sales-agent-from-an-endpoint-alone-is-refused).

The list is for the seller to choose from. It never tells a buyer which partner
a seller uses, and Buyer accounts never receive it. A partner adds
`signupUrl` to its published setup as described in
[Add a sign-up link to your connection setup](https://docs.apostra.com/v3/connect-an-agent.md#add-a-sign-up-link-to-your-connection-setup).

Interactive seller Pages that run inside a host use the account-resolved v3
surface for these reads and writes. In Apostra's hosted bridge, the nested
`/mcp/:toolName` proxy accepts `surface: "v3"` and forwards the call to
`/mcp/v3`; callers still use the tool's canonical arguments, not REST paths.

The [Seller RFP lifecycle](https://docs.apostra.com/v3/rfp-lifecycle.md) guide defines the
`save_rfp` and `save_rfp_turn` fields, polling states, complete response requirements, and
clarification/failure behavior.

The [RFP performance](https://docs.apostra.com/v3/rfp-performance.md) guide defines aggregate
metric formulas, valid dimensions, disclosure thresholds, and immutable
pagination. Individual RFP facts remain on `get({ kind: "rfp" })`.

Material is evidence and teaching input. It does not replace inventory sources,
products, pricing, Playbook instructions, business rules, signals, formats, or
Seller identity. Use
`get({ "kind": "material", "id": "MATERIAL_ID" })` to inspect the current
source revision, ingestion state, diagnostics, typed candidates, confirmation
receipts, bounded rendition summaries, and source artifact references. Explicit
includes expose independently paginated rendition blocks, reusable visual
assets, extraction diagnostics, composition receipts, and expiring source
access; originals and derived binaries are never embedded in the response.
Confirm a candidate by calling the exact `proposedMutation.tool` with its
arguments. Material prose is cited directly and is not a candidate to accept,
reject, or correct. Source records may carry supplied original and
derived structure references such as page or slide order, geometry,
table/image/chart assets, OCR/caption artifacts, digests, provenance,
confidentiality, and reuse rights. Provenance on a structure node is a list of
`{ key, value }` entries when you send it. Completeness is reported per
extraction facet (`text`, `structure`, `tables`, `visuals`, `styles`), and
unsupported or degraded parsing is never presented as ready. See
[Seller workflows](https://docs.apostra.com/v2/setup/v3/seller-workflows.md) for upload finalization,
selection, retention, and access behavior.

### Material search filters

`search({"kind":"material"})` can narrow Library browse rows before paging.
The returned `total`, `nextCursor`, and `hasMore` describe that filtered result
set, not the unfiltered Library.

| Field | Accepted values | Behavior |
| - | - | - |
| `filter.documentPurpose` | `sales_deck`, `one_sheet`, `case_study`, `response`, `specification_sheet`, `uncategorized` | Filters by the seller-governed Library category. `uncategorized` also matches documents with no stored category. |
| `filter.sourceExtension` | A lowercase source extension, such as `pdf` or `pptx` | Filters by the source file type shown in the Library. |

## MCP App and resource-bound tools

These tools support interactive Pages or resources. A compliant host may keep
some of them out of model context and invoke them only through the associated
app contract.

The Product Marketing Page receives a session-bound capability from
`open_page`. It uses the host-only `teach_search_materials`,
`teach_get_material`, `teach_save_material`, and `teach_renew_capability`
aliases for Material listing and exact reads, seller-private URL and file
additions, upload finalization, Material-owned candidate decisions, and
capability renewal. MCP clients continue to use the public `search`, `get`, and
`save_material` tools. The bridge does not permit generic Material writes or
return source-access or visual-preview URLs. Expiring upload credentials are
returned only for the transient browser `PUT` step.

An active, unexpired Demo can also run the fixed synthetic evaluation from the
Product Marketing Page. These app-only tools require the same session-bound Teach capability
and cannot be invoked as general chat actions:

| Tool | Input | Result and limit |
| - | - | - |
| `teach_get_evaluation_status` | `{}` | Returns the current `materials_not_loaded`, `review_candidates`, or `ready` state, durable material and pending-candidate counts, and the synthetic-use disclosure. |
| `teach_prepare_synthetic_materials` | `{ clientRequestId }` | Prepares the fixed six-source synthetic teaching set. An `incomplete` response lists material IDs already saved; retrying continues the same generation. |
| `teach_start_synthetic_evaluation` | `{ clientRequestId }` | After every candidate is reviewed, starts the server-selected held-out brief and returns its exact evaluation RFP and turn. The caller cannot supply or replace the brief. |

All results are rehearsal-only. Synthetic rate-card figures are planning
examples, not live prices or buyer-quotable rates. Custom fulfilment requires a
named human or Connect follow-up; the evaluation does not make it automated or
available now. See [Controlled synthetic evaluation](https://docs.apostra.com/v2/storefront/product-marketing/overview.md#controlled-synthetic-evaluation)
for the full seller workflow.

The Library Page uses the host-only `library_*` aliases the same way, including
`library_get_rfp_turn` and `library_open_proposal_pass` for opening the
authorised Proposal Pass of a response from the Usage view; see
[MCP Apps](https://docs.apostra.com/v2/reference/mcp-apps.md#page-capability-and-tool-bridge) for the
full list.

| Tool | Account | Purpose |
| - | - | - |
| `create_feed_modular_inventory_source` | Seller | Open the modular-source creation Page |
| `seller_approvals_page` | Seller | Operate approval actions that remain interactive |
| `get_modular_inventory_source_readiness` | Seller | Read resource-bound modular readiness |
| `prepare_modular_source` | Seller | Prepare a modular source from its Page flow |
| `get_inventory_feed_context` | Seller | Read inventory-feed context |
| `preview_inventory_feed` | Seller | Validate and preview an inventory feed |
| `commit_inventory_feed` | Seller | Commit a reviewed inventory feed |
| `seller_notification_page` | Seller | Open Seller notification settings |
| `open_media_buy_timeline` | Seller | Pending Operations Page-only launch of the timeline for one media buy |
| `open_media_buy_retry` | Seller | Pending Operations Page-only launch of the confirmation Task that re-sends one failed media buy forward |
| `open_source_diagnostics` | Seller | Pending Operations Page-only launch of diagnostics for one degraded inventory source |
| `resolve_gam_cleanup` | Seller | Pending Operations Page-only commit of a GAM cleanup the seller confirmed in the Page (archive the failed order or record manual cleanup) |
| `seller_media_buy_reporting_page` | Seller | Media Buy Timeline Page-only read of one buy's reporting summary for a named buyer |
| `buyer_notification_page` | Buyer | Open Buyer notification settings |
| `sessions_get_brief_artifact` | Seller | Sessions Page-only read of the brief linked by a recorded RFP request event |
| `sessions_open_media_buy_timeline` | Seller | Sessions Page-only launch of the timeline linked by a `media_buy` downstream reference |
| `get_buyer_sellers_page` | Buyer | Temporary compatibility read for cached Media Partners Page bundles |
| `get_buyer_advertisers_page` | Buyer | Load the Advertisers Page's account landing projection |
| `buyer_sellers_page` | Buyer | Temporary compatibility write for cached Media Partners Page bundles |
| `open_add_advertiser` | Buyer | Open the advertiser setup Task from the Media Partners Page |
| `set_storefront_operator_domain` | Seller | Save the brand domain from the Listing page |
| `get_discovery_publication` | Seller | Read Discovery visibility, publish state, and OpenAI Apps verification status |
| `publish_discovery` | Seller | Set Discovery visibility to Apostra or Public and publish it (Public requires a verified public listing domain, gated by rollout). The Page's separate, confirmed Unlist action sends `private`, publishing a private revision that removes the storefront from Apostra and the public web; `private` is a state action, not a third distribution choice. |
| `save_discovery_hostname` | Seller | Save or replace the storefront's public listing domain |
| `activate_discovery_hostname` | Seller | Verify or re-verify the storefront's public listing domain |
| `download_discovery_printable_card` | Seller | Page-only browser download of a verified printable listing PDF from the current published revision |
| `update_discovery_openai_challenge` | Seller | Set or remove the OpenAI Apps verification token |
| `probe_discovery_openai_challenge` | Seller | Re-check OpenAI Apps verification status on demand |
| `get_chatgpt_app_config` | Seller | Read the ChatGPT app's listing projected from the seller's listing, hostname/DNS state, verification-token status, and submission-bundle availability |
| `save_chatgpt_app_listing` | Seller | Save the ChatGPT app's category and icons — name, subtitle, description, and every URL project read-only from the seller's listing |
| `save_chatgpt_app_hostname` | Seller | Save the ChatGPT app's custom hostname |
| `activate_chatgpt_app_hostname` | Seller | Verify DNS/TLS ownership and activate the ChatGPT app's hostname |
| `save_chatgpt_app_verification_token` | Seller | Set or remove the ChatGPT app's own OpenAI Apps verification token |
| `probe_chatgpt_app_verification_token` | Seller | Re-check the ChatGPT app's verification status on demand |
| `get_chatgpt_app_bundle_url` | Seller | Mint a fresh, short-lived signed submission-bundle download URL, run at click time |
| `approve_chatgpt_app_listing` | Seller | Record the app owner's explicit approval of the projected listing, and optionally acknowledge a legacy-listing reconciliation conflict |

The Buyer Page companion tools in this table are app-only and are not part of
the model-visible buyer catalog.
Current Media Partners Page bundles use public V3 `search`, `get`, `save_connection`,
and `save_ask`; the two closed Media Partners Page facades remain only for a rolling
deploy window. Current Campaigns bundles instead use the resource-bound
app-only `buyer_campaigns_page_search` and `buyer_campaigns_page_get` tools,
backed by canonical V3 projections; Advertisers uses its closed
landing-projection read. None of these Pages receives the generic `api_call`
surface.

Except for `download_discovery_printable_card`, the Listing and ChatGPT
destination tools above are shared between the model-visible seller catalog
and the Listing page (see [Seller Pages](https://docs.apostra.com/v2/setup/seller-pages.md)). They use
the same account, administrator, and confirmation checks in both paths. The
printable listing tool is app-only because it returns PDF bytes for a browser
download; print fulfillment and provider orders remain outside the tool.

## Core read examples

List objects:

```json theme={null}
{ "kind": "campaign", "query": "autumn", "limit": 25 }
```

Read one object with related detail:

```json theme={null}
{
  "kind": "campaign",
  "id": "CAMPAIGN_ID",
  "include": ["mediaBuys", "creatives", "tracking"]
}
```

Campaign `proposals`, `recentActivity`, `deliverySummary`, and `sourceIdentity`
are accepted include names so the API can return a precise
`unavailableIncludes` explanation, but they are not populated in the preview.

Search documentation:

```json theme={null}
{
  "query": "how does publisher authorization work?",
  "sources": ["docs"]
}
```

Read a selected documentation result by its returned `document` path:

```json theme={null}
{
  "query": "how does publisher authorization work?",
  "document": "storefront/inventory-sources/publisher-properties-coverage",
  "sources": ["docs"]
}
```

Arbitrary web URLs and internal repository paths are not readable through the
documentation search contract.

## Read structured results

Every tool returns MCP `content` for a person or model to read. Integrations
should make decisions from `structuredContent`. `tools/list` is authoritative
for inputs and publishes an output schema where the current tool declares one;
the patterns below cover the remaining preview tools.

| Tool family | Stable result pattern |
| - | - |
| `search` | A kind-specific object block with `results`, counts, `hasMore`, and an opaque `nextCursor` when another page exists |
| `get` | `{ kind, object }`, plus requested related data and `unavailableIncludes` when an accepted include cannot be populated |
| `save_<noun>` | `action` plus the saved noun or its ID; versioned nouns return the revision needed for the next guarded write |
| `request_proposals` | `executionId`, `status`, a full-cohort summary, one bounded `perSeller` page, `page.nextCursor`, and `skippedSellers` when channel groups left eligible sellers out; each seller carries an outcome, returned property coverage, Proposal IDs/a `productQueryId` and products, or a bounded error |
| `get_delivery` | Report rows, coverage and finality metadata, warnings, and pagination state |
| `get_rfp_performance` | Grouped metric datums, population, freshness, provenance, finality, disclosure semantics, and immutable pagination |

Do not parse IDs, revisions, actions, or recovery guidance out of rendered
prose. A write may return `action: "unchanged"`; that is a successful
idempotent outcome. On partial work, preserve `partialWrite`, `warnings`,
`errors`, per-seller or per-source outcomes, and any returned current revision.
`request_proposals.status` is normally `running` while durable background work
contacts the cohort; retry that same key until it becomes `complete`,
`partial`, or `failed`. A buyer can have only one running proposal execution
across campaigns. Terminal result cursors can continue a product-heavy outcome
for the same seller, and `detailsTruncated` marks byte-bounded product fields.
Each terminal seller result has one safe `outcome`: products returned, no
matching products, no inventory for the request, a skipped seller, a failed
seller, or `local_refusal` when the request was refused by Apostra's own
validation before it ever reached that seller (with a `code`, `message`, and
an optional `suggestion`). `no_inventory_for_request` is a completed seller
choice, not a failure or retry instruction. `skipped` with `reasonCode`
`advertiser_activation`
means the seller is off for the campaign's advertiser and was not contacted;
the seller's `error.message` says where to turn it on. `skipped` with
`reasonCode` `account_pending` or `account_needs_attention` means the
campaign's advertiser has no active account with that seller yet: setup is on
its way, or someone has to act, and the seller's `error.message` points to
Media Partners.
`skipped` with `reasonCode` `round_predates_account_rule` means the round was
queued before requests recorded who sent them, so the seller's company account
could not be presented and the seller was not asked; the seller's
`error.message` says to run the request again, which asks it as the advertiser's
own account. A seller that is closed to buyers is never reported with an
account reason: it is `skipped` with `reasonCode` `eligibility`, including a
closed seller named by a buyer that seller sponsors.
None of these sellers is contacted, because discovery asks a seller only as
the advertiser's own account with it. They appear only for a seller you named,
through `sellerAccountIds` or the campaign seller list. When
neither applies, `request_proposals` contacts only the eligible sellers that
are on for the campaign's advertiser and where the advertiser has an active
account; the others are not contacted or listed. If none qualifies, the
request fails with `NO_ELIGIBLE_SELLERS` and a message saying where to turn a
media partner on, or how many sellers are still setting up the advertiser's
account and how many need attention. `propertyCoverage` counts the property domains present in
returned products. When a seller reports filter accounting or unfinished work,
`filterResult` distinguishes no match, filtered-out inventory, and partial
results without exposing seller operational detail.
When a seller cannot execute an exact age range, its proposal remains available
as a counter-pitch and its semantic document names the limitation and the
seller's supported ranges. A direct `get_products` response carries the same
information in its additive `limitations` array: `code`, buyer-readable
`message`, requested `min`/`max`/`include_unknown`, and `supported_ranges`.

### AI-8599 bounded targeting continuation (cursor protocol v1)

Capability details can exceed a model-safe response page. V3 therefore exposes
two signed, opaque continuation fields; they are discovery/readback only and
do not contact a seller or start a new proposal round.

| Call | Field | Use |
| - | - | - |
| `request_proposals` | `capabilityCursor` (optional, maximum 4,096 characters) | Continue capability details from the same completed idempotent request. Reuse the original campaign, expected revision, and idempotency key; do not combine it with `resultCursor`. |
| `get({ kind: "proposal", include: ["products"] })` | `proposalProductsCursor` (optional, 1–2,048 characters) | Continue that Proposal's product/capability text at the same revision. This field is invalid for every other kind or without `include: ["products"]`. |

Cursors are account- and snapshot-bound. A forged, stale, or wrong-scope cursor
returns a validation error; a Proposal's expiry remains explicit in its
read-only `state` and never gives a cursor execution authority. Follow a
continuation only while the response supplies its next cursor. A terminal page
has `complete: true` and no next cursor. A capability marked unavailable or
blocked is not complete actionable targeting: demographic selections remain
rejected before seller dispatch while the application seller transport remains
on AdCP 3.1. If a signed continuation cannot fit its approved field limit, V3
omits the token and marks the capability unavailable rather than truncating its
scope or presenting omitted details as complete.

For example, a successful advertiser creation returns the new identity under
the noun rather than as an unlabelled string:

```json theme={null}
{
  "action": "created",
  "advertiser": {
    "advertiserId": "ADVERTISER_ID",
    "name": "Acme Europe",
    "primaryCurrency": "EUR"
  }
}
```

`request_proposals` returns enough state to continue each seller branch:

```json theme={null}
{
  "executionId": "EXECUTION_ID",
  "status": "partial",
  "perSeller": [
    {
      "sellerAccountId": 2192,
      "status": "quoted",
      "proposalIds": ["sfp1:QUALIFIED_PROPOSAL_ID"]
    },
    {
      "sellerAccountId": 2210,
      "status": "failed",
      "error": { "code": "UPSTREAM_ERROR", "message": "..." }
    }
  ],
  "summary": {
    "sellersRequested": 2,
    "sellersQuoted": 1,
    "sellersWithProducts": 0,
    "sellersResponded": 1,
    "sellersFailed": 1,
    "sellersPending": 0
  },
  "page": { "returned": 2, "total": 2, "hasMore": false }
}
```

These examples show the continuation fields, not every property. Treat the
live output schema as authoritative as the preview evolves.
Follow every `page.nextCursor` before treating the review as complete.

## Errors and result handling

Tool errors use bounded structured codes and explanatory text. Common classes
include validation errors, access denial, not found, revision conflict,
unsupported preview behavior, external failure, and partial results.

* Treat `isError: true` as a failed tool result even when the MCP transport
  itself returned successfully.
* Preserve structured IDs and recovery fields; do not parse prose to recover an
  identifier already present in `structuredContent`.
* A partial result is not an empty result. Read its warnings, coverage, and
  per-source or per-seller outcomes.
* Re-read after any response that reports a partial write.

For transport-wide behavior, see [Errors](https://docs.apostra.com/v2/reference/errors.md),
[Rate Limits](https://docs.apostra.com/v2/reference/rate-limits.md), and
[Preview limitations](https://docs.apostra.com/v3/limitations.md).


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