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

# Buyer Workflows

> Use v3 to create buyer objects, request seller proposals, stage media buys, and launch a campaign.

<Note>
  These workflows require an enrolled Buyer account or the integrated
  own-supply sandbox capability on a Media Company. Call `get_status` first.
  An integrated Media Company stays in the same account and never uses
  `switch_account` for campaign work.
</Note>

## Inspect account activity

In the standard buyer workspace, open **Activity** from the **Account** group
in the left navigation. Activity is available before you select an advertiser
and shows the account's Calls and Changes. Advertiser and campaign filters
narrow the view; they do not control whether Activity is available.

The integrated buyer campaign workspace keeps its campaign-focused
navigation. In the Agents workspace, open **API calls** to inspect the same
account-level activity without leaving that workspace.

When you request proposals through Murph, its runtime continues the same request for up to eight dispatches within 30 seconds. It preserves the campaign revision and idempotency key. An unfinished response is provisional, including when no products have arrived; it does not mean there are no matches. If the wait limit is reached or accumulated result pages exceed its response-size limit, Murph retains the request and reports incomplete results. It does not automatically start a replacement round or attach provisional products. Completed results still follow the normal product-selection and attachment checks. Reading full targeting capability pages preserves the original request and any confirmed attachments. If a first request is rejected before execution starts, you can correct it and retry in the same conversation. Errors during an existing execution or with an uncertain outcome keep that execution protected from replacement.

## Buyer workflow at a glance

1. Confirm buyer operator readiness.
2. Create or select an advertiser.
3. Create a campaign with its brief, flight, and budget.
4. Request proposals from ready sellers.
5. Accept a quoted proposal or stage returned products.
6. Inspect the staged media buys and resolve any draft issues.
7. Launch the campaign explicitly.
8. Query bounded campaign delivery.

## 0. Confirm buyer operator readiness

Call `get_status` before creating buying work. When
`operatorIdentity.usableForBuying` is false, new discovery and buying calls are
blocked with `BUYER_SETUP_REQUIRED`. An account administrator must call
`save_buyer_operator` with the buyer's real non-platform domain and choose
whether this account represents the `whole_operator` or a `specific_unit` with
a stable AdCP `operator_unit` (`{ "id": "east-coast" }`).

```json theme={null}
{
  "operatorDomain": "agency.example",
  "operatorScope": "whole_operator"
}
```

When the domain is usable, `scopeStatus` is `unclassified`, and `locked` is
false, reuse that domain and choose its scope before new AdCP 3.2 provisioning.
Existing buying remains available during that scope migration. If the identity
is locked, follow the support action from `get_status` instead.
Confirming the commercial operator does not add users, change membership, or
change the login organization. See
[Buyer setup and go-live](/v2/buyer/account/setup) for scope selection and
identity-locking rules.

## 1. Create an advertiser

Creating requires `name` and `brand`. `primaryCurrency` defaults to `'USD'`
when omitted; pass any ISO 4217 code to override, or update it later while the
advertiser is unlocked. When `name` or `brand` is missing the tool returns a
neutral `needs_input` result naming the missing fields. A `needs_input` result
is a question, not a failure; nothing is saved until the tool is called again
with the answer.

The currency stays editable until the first campaign or seller binding locks
it. To change it, send `advertiserId` with the new `primaryCurrency` as an
ordinary update; you never need to archive and recreate the advertiser.
`sandbox` is immutable after creation.

```json theme={null}
{
  "name": "Acme Europe",
  "brand": "acme.example",
  "primaryCurrency": "EUR",
  "sandbox": true
}
```

Retain the returned `advertiserId`. To update, send it with only the fields to
change. Do not send `sandbox` on an update.

## 2. Create a campaign

Creation requires `advertiserId`, `name`, and an `idempotencyKey`. `flight`
and `budget` are optional for a draft. Add them when they are known, or save
the draft first and complete its terms later. Write the brief from the buyer's
stated goal, audience, and what is being promoted.

```json theme={null}
{
  "advertiserId": "ADVERTISER_ID",
  "name": "Autumn launch",
  "brief": "Reach sustainability leaders in the Netherlands with Acme's autumn product launch.",
  "flight": {
    "startAt": "2026-09-01T00:00:00Z",
    "endAt": "2026-09-30T23:59:59Z"
  },
  "budget": {
    "total": 50000,
    "currency": "EUR",
    "pacing": "even"
  },
  "idempotencyKey": "acme-autumn-create-v1"
}
```

Creation does not launch. Retain the returned `campaignId` and `revision`.

In the Campaigns Page, **New campaign** creates an untitled draft in one click
when the Page is scoped to an advertiser. An unscoped Page first asks you to
choose an advertiser. The campaign opens immediately, where you can edit
the name, budget in the advertiser's currency, and flight in the advertiser's
reporting time zone. Tracked campaigns remain read-only. In chat, ask for a
campaign and the agent creates the draft from your request, then opens the
campaign.

### Cap exposure within each seller media buy

Set `frequencyCap` with `level: "mediaBuy"` before requesting proposals when
the buyer wants one AdCP 3.2 counter across the participating packages bought
from each seller. Discovery limits the result to sellers and products that
declare compatible support, and launch checks that support again.

Each seller has an independent counter. This field does not cap exposure across
different sellers. See [Media-buy frequency caps](/v2/buyer/campaigns/media-buy-frequency-caps)
for the request shape, supported reach units, and current level boundaries.

### Target geographic areas

#### Update `save_campaign` targeting

`save_campaign` now accepts the AdCP `targetingOverlay` field. The
requirement-shaped `targeting` field is **deprecated, with behavior unchanged**.
Do not send both fields in one call. Removal is a separately announced change
(AI-10440) with at least 14 days' notice. Send canonical codes in the new field.

Before:

```json theme={null}
{
  "targeting": {
    "geo": [{
      "requirementId": "california",
      "strength": "required",
      "include": ["US-CA"]
    }]
  }
}
```

After:

```json theme={null}
{
  "targetingOverlay": {
    "geo_regions": ["US-CA"],
    "language": ["en"]
  }
}
```

Campaign targeting is the default for proposal discovery and for a product
that omits `targetingOverlay`. An explicit `targetingOverlay` on a product
supplies that buy's targeting, and campaign targeting still narrows it:
overlapping values are kept, and a product value outside the campaign returns
`CONFLICT`. Media-buy readback returns each package under AdCP `Package`
names (`package_id`, `budget` as a number in the buy's currency, `bid_price`,
`performance_standards`), with the effective `targeting_overlay` and its
`targeting_source` (`campaign` or `buy`). The source is recorded when the
draft is staged, so later campaign changes never rewrite the buy's history.

On `save_media_buy`, `products[].targetingOverlay` accepts the AdCP package
targeting overlay except `signal_targeting`. Typed `signal_targeting_groups`
remains supported. `signal_targeting` is a priced, product-scoped selection and
will come through the signals path. The overlay includes geographic fields,
frequency caps, property and collection lists, placement and collection
selection, audiences, browser, language, device, age restriction, dayparts,
store catchments, proximity, and keyword targeting. Sellers enforce the fields
their returned product supports.

`demographics` remains discovery and readback only while seller transport is
on AdCP 3.1. `save_media_buy` rejects it before contacting a seller. Do not add
`targetingOverlay` when updating an existing draft; it is create-only.

Countries accept ISO 3166-1 alpha-2 codes or names and regions accept ISO
3166-2 codes or names; both resolve to canonical codes before dispatch. Postal
areas use the country-aware AdCP shape. Each geographic field must contain at
least one value.

| Field | Values | Resolution |
| - | - | - |
| `geo_countries` / `geo_countries_exclude` | ISO 3166-1 alpha-2 codes or country names | Names resolve to canonical country codes before dispatch. |
| `geo_regions` / `geo_regions_exclude` | ISO 3166-2 codes or subdivision names | Names resolve to canonical subdivision codes before dispatch. |
| `geo_metros` / `geo_metros_exclude` | Nielsen DMA entries (`system`, `values`) | Deprecated. Numeric codes replace names. Removal will be announced in release notes at least 14 days ahead; planned for 2 November 2026 (AI-10440). Codes must be in the Nielsen DMA dictionary; unknown codes return nearby code candidates. |
| `geo_postal_areas` / `geo_postal_areas_exclude` | Country-aware postal-area entries | Preserve the AdCP country and postal-system shape. |

Nielsen Designated Market Areas (DMAs) remain available while saving a
campaign or staging a media buy. On `save_campaign`, use
`targeting.geoMetros`. On `save_media_buy`, use
`products[].targetingOverlay.geo_metros` to include DMAs or
`geo_metros_exclude` to exclude them.

Campaign targeting can still use its V3 requirement form. On a media-buy
overlay: Deprecated. Numeric codes replace names. Removal will be announced in
release notes at least 14 days ahead; planned for 2 November 2026 (AI-10440).
Codes must be in the Nielsen DMA dictionary; unknown codes return nearby code
candidates.

```json theme={null}
{
  "targeting": {
    "geoMetros": [{
      "requirementId": "us-west-dmas",
      "strength": "required",
      "system": "nielsen_dma",
      "include": ["LA DMA"]
    }]
  }
}
```

```json theme={null}
{
  "products": [{
    "productId": "QUALIFIED_PRODUCT_ID",
    "targetingOverlay": {
      "geo_metros": [{ "system": "nielsen_dma", "values": ["803"] }],
      "geo_metros_exclude": [{ "system": "nielsen_dma", "values": ["501"] }]
    }
  }]
}
```

`save_media_buy` returns an actionable validation error when it cannot resolve
a DMA name. Do not guess a code.

## 3. Request proposals from eligible sellers

`get_status` reports how many destinations are currently ready and includes a
bounded `readyDestinations` sample for explanation. **Ready means the seller
will be considered for contact** when you make a fresh `request_proposals`
call: it has an active sales agent and meets the buyer-specific `canBuy`
checks. The request rechecks the complete marketplace server-side before it
contacts that same group. Campaign constraints and the fresh recheck can still
exclude a seller from contact.

### Seller cohort rules

The server determines the final cohort:

| Input | Behavior |
| - | - |
| `sellerIds` on `request_proposals` and campaign seller list both set | Intersects them: only sellers in both are contacted. If any requested seller is outside the campaign list or unavailable, the whole round is refused. |
| Campaign has a seller list, no `sellerIds` on the call | Dispatches to the eligible subset of the campaign's seller picks and reports unavailable campaign sellers. |
| No campaign seller list, `sellerIds` provided on the call | Contacts exactly those sellers only when all are currently eligible; otherwise the whole round is refused. |
| Neither selector is set | Set `confirmBroadcast: true` to broadcast to all currently eligible, buyer-active sellers. Without it, the request is refused before any seller is contacted. |

When `sellerIds` is supplied, any requested seller outside the campaign list, outside an
authorized seller scope, or not currently eligible returns
`CAMPAIGN_SELLERS_INELIGIBLE` with canonical seller IDs and contacts nobody. Existing
calls that supply `sellerIds` now narrow the round and never reach sellers outside that
scope. Without `sellerIds`, a campaign list instead contacts its eligible subset and
reports unavailable campaign sellers. The market requirement below is checked before any
seller cohort is contacted.

To broadcast, omit `sellerIds` and use a campaign with no seller list, then set
`confirmBroadcast: true`. Existing callers that supply `sellerIds` now narrow the round;
they do not broadcast. The response's `appliedCohort` shows whether the round used the
requested seller IDs, the campaign seller list, the authorized seller scope, or a confirmed
broadcast, along with the bounded seller IDs contacted.

**Channel groups narrow the round.** When the campaign has
[channel groups](/v2/buyer/campaigns/channel-groups#proposal-rounds-follow-your-channel-groups),
a seller whose declared channels match none of them is not contacted, and the
response names it in `skippedSellers` with the reason `channel_mismatch`. An
audio-only campaign therefore does not contact display-only sellers. Returned
products that fit none of the groups are dropped and counted on the seller's
outcome as `channelExcludedProductCount`.

**A proposal round needs a market.** Set at least one country on the campaign
with `save_campaign` `targetingOverlay.geo_countries` (for example
`["US"]`) before calling `request_proposals`. A region in
`targetingOverlay.geo_regions`, a Nielsen DMA in
`targetingOverlay.geo_metros`, or a postal area also counts: the round reaches
the sellers that cover that country. Exclusions alone name no market. Scope3
does not read geography out of the campaign's outcome text, so "US only"
written there is not targeting until it is set on the campaign.

`request_proposals` enforces this requirement: it refuses a campaign whose
targeting names no country and contacts no seller. The `VALIDATION_ERROR` names
`targetingOverlay.geo_countries`; set it (or `targetingOverlay.geo_regions` or
`targetingOverlay.geo_metros`) with `save_campaign`, then retry with the new
campaign revision.

Every solicited seller receives the same brief: the campaign's stated outcome,
flight, geography, channels, language, device constraints, required creative
formats, audience requirements by reference, and the campaign's ranked
optimisation goals with their targets. A seller reads a goal such as
"clicks, target cost 3.00 or less per click in the buy currency, counted by the seller's delivery metrics"
and can price against it. Event goals disclose the event type and never your
event source id or tracker configuration. The campaign budget, pacing schedule,
other sellers being considered, and internal configuration are withheld; the
categories withheld from each seller are recorded on the brief artifact.

<Note>
  **Which call a seller receives.** Sellers are contacted through AdCP
  `get_products` in brief mode and answer with proposals inside that response.
  A seller whose capabilities declare proposal support, either as
  `media_buy.supports_proposals: true` or by listing `request_proposals` in
  `media_buy.lifecycle_tools`, and that declares the exact AdCP 3.2 release
  (`3.2` in `adcp.supported_versions`) instead receives the canonical `request_proposals`
  call with the same brief and its criteria (the flight, geography and channel
  filters, and any targeting overlay), and its canonical proposals are stored
  with their commercial terms, so the commitment recorded for each proposal is
  read from the seller's own per-purchase terms. Sellers without that declaration
  keep receiving `get_products`. A seller that declines to propose is recorded as
  that seller's failure with its stated reason, never as an empty result. The
  canonical call can also carry the primary cost goal as a structured outcome
  target with no volume, to sellers that declare they can plan one (see
  [What sellers see](/v2/concepts/goal-seeking-campaigns#what-sellers-see)). The
  campaign budget is never sent.
</Note>

Before a round, state which sellers will be contacted. If the buyer has not
asked to contact all eligible sellers, get an explicit yes before calling with
neither list. After the round, say who was contacted.

Before a seller-visible brief is recorded or sent, each seller must also pass
the campaign's structured market and channel constraints.

For an active sponsored buyer using a sandbox advertiser, the server instead
confines the cohort to that buyer's sponsoring storefront and applies the
sandbox transaction path. The sponsoring storefront does not need to be open
to the public marketplace for this no-spend workflow.

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "expectedCampaignRevision": 1,
  "sellerIds": ["SELLER_ID_1", "SELLER_ID_2"],
  "expectedSellerId": "EXPECTED_SELLER_ID",
  "evaluation": {
    "instructions": "Prefer contextual relevance and transparent fixed pricing."
  },
  "idempotencyKey": "acme-autumn-proposals-v1"
}
```

`expectedSellerId` is a Storefront ID, not an internal customer ID. It is an
optional fail-closed precondition for automation that must remain confined to
one seller. A fresh round fails if the buyer's current
server-side authority does not resolve exclusively to that seller. The value
can narrow an already-authorized scope; it cannot authorize a seller or reduce
a normal marketplace buyer's complete eligible cohort. Omit it when broad
marketplace discovery is intentional.

For a fresh round, the call durably schedules complete eligibility enumeration
and returns `running`; the frozen seller count may therefore be zero on the
first response while discovery is pending. Each background seller attempt has a
30-second bound. Retry the exact same idempotency key until the result becomes
`complete`, `partial`, or `failed`. The execution
stores the resolved seller cohort, so retries never silently add, remove, or
duplicate sellers. Each seller may return:

* `quoted` with qualified Proposal IDs;
* `products` with a `productQueryId`; or
* `failed` with a bounded error.

Repeating the same idempotency key returns the same proposal round. Use a new
key only when intentionally asking sellers for a fresh round. Evaluation
instructions are recorded but are not yet applied to ranking; review the
returned results yourself.

Only one proposal-request execution may run for a buyer at a time, across all
campaigns. Poll the active execution to terminal before starting another.

After the execution is terminal, follow every `page.nextCursor`. `perSeller`
contains at most 50 outcomes on the current page, while product-heavy outcomes
may continue for the same seller on the next cursor. Product details are
bounded for transport; `detailsTruncated: true` marks a bounded field projection.
The `productId` remains the selection key. Meanwhile,
`summary.sellersRequested` always counts the full frozen cohort.

## 4. Stage a media buy

### Read what a seller committed to

A proposal's `allocations`, and each row of its `products` include, use AdCP
`ProductAllocation` names: `product_id`, `allocation_percentage`,
`pricing_option_id`, and `rationale`. Per-seller errors from
`request_proposals` are AdCP errors (`code`, `message`); a seller's own
explanation, when it gave one, is in `seller_explanation`, fenced as untrusted
text. A product's `resolvedAgeTargeting` is an AdCP age range (`min`, `max`,
`include_unknown`); `max` is left out for an open range such as 65+.

Every quoted proposal carries `goalAnswers`, one entry per allocation, and a
proposal-level `commitment`. They are read off the seller's terms, never its
prose: the pricing option it chose for the allocation, the product's delivery
type and measurement terms, and any optimisation goals the seller declared
when it asked to optimise the budget itself. Each answer names the pricing
model and fixed price, the delivery type, the goal you asked for, the target
the seller's terms actually commit or aim at (`answeredTarget`), and a
`commitment` kind:

| Commitment | What it means | How it is read |
| - | - | - |
| `guaranteed` | The seller is on the hook for the price per result. | A fixed price on the outcome itself (`cpc`, `cpa`, `cpcv`, `cpv`), and either guaranteed delivery or a makegood policy in the measurement terms. |
| `best_effort` | The seller will try, and has named a number or priced the outcome. | A fixed outcome price without guaranteed delivery or remedies, an outcome price without a fixed number, a seller optimisation goal with a target for your goal, or, from a seller on AdCP 3.2, the average cost it plans to for your goal in `commercial_terms.bidding.cost_per`. A fixed outcome price outranks that planned cost when both are present. AdCP treats a cost-per bid target as "not a per-result guarantee", and measurement terms alone only say how a result is counted. |
| `report_only` | Nothing in the terms is tied to your goal. | Exposure pricing (`cpm`, flat rate) with no seller goal for what you asked, or a fixed price on a different outcome. |

`commitment.kind` is the weakest kind across allocations, because a proposal
is only as committed as its least committed part; `commitment.answeredTarget`
is the worst answered target across allocations (the highest cost per unit,
the lowest rate, or the lowest return); `commitment.meetsAskedTarget` is
`true` only when every allocation answered your target and met it by its
kind (a cost at or under your ask, a rate or return at or above it), `false`
when any allocation missed it or did not answer with the same kind, and `null`
when your goal carries no target or a maximise target. Proposals recorded before this field existed
are read the same way from their stored terms, without the asked goal.

Each goal answer also carries `goalCoverage` when the proposal kept the
product's AdCP optimization declaration: `coversPrimaryGoal`, and `uncovered`,
listing each goal the product cannot optimize to by `priority` (1 is primary)
with a reason `code`. Proposal-less products returned by `request_proposals`
carry the same `goalCoverage`. In text, a proposal read and the
`request_proposals` result name each such product with its uncovered goals,
for example `priority 1 (metric_not_declared)`. A product that declares no
optimization capability covers no goal. You can still book it; it reports
against the goal rather than optimizing to it.

When your primary goal is a `views`, `viewable_rate` or `completed_views` goal
with a target and the seller's forecast carries a range for it,
`goalCoverage.expected` adds what the product is expected to deliver: the
rate's `basis`, its `low`, `mid` and `high` (0 to 1), the rate `required` by
your target (for a cost target, at the allocation's CPM), and a `verdict` of
`likely`, `possible` or `unlikely`. Text names it in the same line, for
example `expected viewable rate 78%-88% vs 70% needed (likely)`, and the
`request_proposals` result shows it as `primaryGoal: ...`.
[Expected performance against your goal](/v2/concepts/goal-seeking-campaigns#expected-performance-against-your-goal)
explains each basis.
[Goal-seeking campaigns](/v2/concepts/goal-seeking-campaigns) explains the
goal statement these answers respond to and who carries the risk under each
commitment kind.

### Accept a quoted proposal

```json theme={null}
{
  "fromProposalId": "sfp1:QUALIFIED_PROPOSAL_ID",
  "idempotencyKey": "acme-autumn-accept-proposal-v1"
}
```

The proposal version must still be current and belong to the campaign. The
result is a draft media buy; accepting a proposal does not launch it. The
current schema requires `idempotencyKey` on every call, although proposal
acceptance derives retry safety from the qualified proposal version and does
not consume the supplied key.

### Stage returned products

For a seller that returned products without a Proposal, preserve every returned
identity field and use that seller's `productQueryId` as the idempotency key:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "sellerId": "10",
  "products": [
    {
      "productId": "QUALIFIED_PRODUCT_ID",
      "inventorySourceId": "200",
      "salesAgentId": "SELLER_AGENT_ID",
      "pricingOptionId": "RETURNED_PRICING_OPTION_ID",
      "budget": 5000,
      "targetingOverlay": {
        "signal_targeting_groups": {
          "operator": "all",
          "groups": [{
            "operator": "any",
            "signals": [{
              "signal_ref": { "scope": "product", "signal_id": "adults_25_54" },
              "value_type": "numeric",
              "min_value": 25,
              "max_value": 54
            }]
          }]
        }
      }
    }
  ],
  "idempotencyKey": "PRODUCT_QUERY_ID"
}
```

Do not reconstruct qualified IDs. When returned, `inventorySourceId`,
`salesAgentId`, and `pricingOptionId` distinguish the exact Product route and
price selected from the returned catalog.
When a returned Product advertises `signal_targeting_options`, select an
eligible Signal through that Product's `targetingOverlay`. Preserve its
`signal_ref`, value type, bounds or values, activation handle, and pricing
identity exactly as returned; the Seller validates eligibility at launch.

`save_media_buy` accepts `flight` when staging from returned products or
accepting a proposal. It accepts top-level `budget` for one returned product or
a one-allocation proposal. For multiple products or allocations, keep the
returned allocation or set `products[].budget` for each selected product.
Accepting a proposal creates one media buy, so every allocation must use the
same settlement currency and seller route. If a proposal spans currencies or
seller routes, `save_media_buy` rejects it before claiming the proposal or
creating a draft; request separate proposals by currency or seller route.

## 5. Inspect staged work

List media buys under the campaign:

```json theme={null}
{ "kind": "media_buy", "filter": { "campaignId": "CAMPAIGN_ID" } }
```

Or list every current buy for an advertiser across its campaigns, paged
(`limit` up to 200; pass back `nextCursor` for the next page):

```json theme={null}
{ "kind": "media_buy", "filter": { "advertiserId": "ADVERTISER_ID" }, "limit": 50 }
```

Each row reports its `campaignId` (when the buy belongs to a campaign), phase,
pause state, flight, the seller (as
`sellerId` plus `sellerName`), and the buy's gross budget. `sellerId` is always
a Storefront ID, never an internal customer ID. It does not retain
Proposal evidence after the acceptance response, so preserve the
`proposalSource` fields returned by `save_media_buy` when that audit link
matters. When both filters are present, `campaignId` wins. An advertiser that
is not in your account returns `NOT_FOUND`, not an empty list.

Read one buy's execution tree with `get`:

```json theme={null}
{ "kind": "media_buy", "id": "MEDIA_BUY_ID", "include": ["products"] }
```

The base object carries `campaignId`, `sellerId`, `sellerName`, `budget`,
`flight`, and the why-visibility fields (`pendingReason`, `errorCode`,
`forwardedAt`, `buyerReference`). When a submitted change is not live yet, it
also carries `pendingChange`; see
[A change that is not live yet](#a-change-that-is-not-live-yet). Includes add:

| Include | Returns |
| - | - |
| `products` | Line items (`products[]`): each seller product with its budget, human-readable accepted formats, and the packages cut from it |
| `creatives` | Creatives attached to the buy, each with a readable format label, status, and seller review state |
| `deliverySummary` | The latest aggregate delivery snapshot, or `null` when none has been recorded yet |
| `liveStatus` | A fresh status read from the sales agent, including the current upstream status, blockers, and any pending reason |

`pendingChange` is also accepted as an include. The base object already carries
it whenever a change is waiting, so asking for it changes nothing. Any other
include is echoed in `unavailableIncludes` with the reason. The
`content[0].text` of every read mirrors these facts — seller names, budgets,
line items, packages, and format labels — so an agent reading only text sees
the same buy a structured-first host does. Proposal reads and
`request_proposals` outcomes name their sellers the same way.

Archive is a visibility flag, not a lifecycle phase. A read or
`search(kind: "media_buy", filter: { isArchived: true })` reports an archived
buy with `isArchived: true` and its preserved phase. This lets you distinguish
an archived draft from a completed or canceled buy.

Older archived buys may report `phase: "completed"` because the previous
archive lifecycle overwrote their status with `ARCHIVED` and did not retain the
earlier phase. `completed` is a compatibility stand-in for those rows, not a
record of their pre-archive phase.

At this step, inspect each draft, apply any supported correction with
`save_media_buy({ mediaBuyId: ... })`, and continue only when the staged set is
the one you intend to launch. Supported corrections on a draft:

* `flight` — move the start or end date.
* `budget` — a new total, on a draft with exactly one line item.
* `products[]` — per line item, change `budget`, `bidPrice`, or
  `pricingOptionId`, or set `remove: true` to drop the line item. A product
  that is not already on the buy cannot be added; stage a new media buy for it.
* `isArchived: true` — remove an unwanted draft, failed, canceled, or rejected
  buy from the buyer's list. The campaign is unchanged. Failed, canceled, and
  rejected buys are removed locally without another seller cancellation. A
  live or pending buy is cancelled through the campaign or the v2 update
  contract.

Anything the draft cannot absorb (a new product, a different inventory source
or seller route, changed Signal targeting, restoring an archived buy) is
refused with `NOT_IMPLEMENTED` naming the field, never silently ignored.

### Change a live media buy

Once a media buy has gone to the seller, `save_media_buy` can change its
`flight` and its budget:

* Before the buy starts, send a new `startAt`, `endAt`, or both.
* After the buy starts, only the end date can change. `flight` needs both
  fields, so send the buy's current `startAt` unchanged with the new `endAt`.
  A different start on a running buy is refused.
* `products[].budget` sets a line item's new total budget, including what it
  has already spent. A top-level `budget` does the same on a buy with one line
  item. The seller spends against the line item's packages, so the change goes
  to the ones still running:
  * a package whose flight or pacing period has ended keeps its budget;
  * the open packages share the rest in their current proportion, and none is
    set below what it has already spent.
* The lowest total you can set is what the ended periods hold (their budget,
  or what they spent if that is more) plus what the open periods have already
  spent, including fees; a lower total is refused with that amount. A budget change to a line item whose flight and pacing
  periods have all ended is refused.
* When the same product is on the buy as more than one line item, name the
  one to change with `products[].lineItemRef`; otherwise the change is
  refused with the line items listed.
* A live buy's bid cannot change here yet.
* Retrying a change with the same `idempotencyKey` and the same request
  returns the first result instead of applying it again. The retry is still
  checked against the buy as it is now, so if the buy changed in between (for
  example it was archived, or its waiting change was rejected), the retry gets
  that refusal instead of the first result; it never applies the change twice.
  The same key with a different request is refused, so use a new key for each
  new change.

To end a running buy on 15 December, read its flight and send the start back:

```json theme={null}
{
  "name": "save_media_buy",
  "arguments": {
    "mediaBuyId": "mb_123",
    "flight": { "startAt": "2026-11-01T00:00:00Z", "endAt": "2026-12-15T23:59:59Z" },
    "idempotencyKey": "end-mb_123-early-1"
  }
}
```

The call waits for the seller to answer:

* `action: "updated"` means the seller confirmed the change and it is live.
* An error with code `SERVICE_UNAVAILABLE` means the seller did not confirm.
  Read the buy before trying again; its `pendingChange` shows whether a change
  is still waiting.
* `action: "pending_approval"` means the call succeeded, but the buy still has
  a change that is not live on it yet. The returned `mediaBuy` shows the live
  values and carries `pendingChange`, described below. Do not send the change
  again or tell the buyer it is done.

### A change that is not live yet

A media buy can have a submitted change that has not reached the delivering
buy yet. A single-buy read, `get({ kind: "media_buy", id })`, and the
`mediaBuy` returned by `save_media_buy` show it as `pendingChange`. Every other
field still describes what is live, and the buy keeps delivering on those
terms until the change takes effect:

* `status` is the status of the change itself, typically `PENDING_APPROVAL`.
  The buy's own `phase` is unchanged.
* `pendingAt` says whether the change is waiting on the storefront operator
  (`storefront`), the seller's sales agent (`salesagent`), or neither can be
  told (`unknown`). It can be absent.
* `differences` compares the flight end (`endTime`), the total `budget`, and
  the attached `creatives` with their live values, and lists each one that
  differs as `live` and `proposed`. A change to anything else, such as a bid,
  can leave it empty.
* `reason` and `proposalId` appear when the change recorded them.

Once the change takes effect, the new values show as live and `pendingChange`
is gone. Do not tell the buyer a change has taken effect while `pendingChange`
is present. Search rows and the campaign `mediaBuys` include do not carry it.

This `mediaBuy.pendingChange` is not the top-level `pendingChange` on a pause
or resume response. That one's `status` is the status the buy moves to once
the seller confirms.

An abbreviated read:

```json theme={null}
{
  "object": {
    "mediaBuyId": "mb_123",
    "phase": "active",
    "flight": { "startAt": "2026-11-01T00:00:00Z", "endAt": "2026-11-30T23:59:59Z" },
    "pendingChange": {
      "status": "PENDING_APPROVAL",
      "pendingAt": "salesagent",
      "differences": {
        "endTime": { "live": "2026-11-30T23:59:59Z", "proposed": "2026-12-31T23:59:59Z" }
      }
    }
  }
}
```

### Pause, resume, or cancel one media buy

To pause one running media buy without pausing its campaign or sibling media
buys, call `save_media_buy` with its `mediaBuyId`, `isPaused: true`, and an
`idempotencyKey`. To resume that same buy, call it again with `isPaused: false`.
Send `isPaused` on its own: do not combine it with a flight, budget, product,
or archive change. Only an `ACTIVE` buy can be paused and only a `PAUSED` buy
can be resumed.

The response's `action` is `paused` or `resumed` once the seller has applied
the change. Some sellers accept a pause or resume and apply it later; the
action is then `pause_requested` or `resume_requested`:

* `isPaused`, `previousStatus`, and `newStatus` keep the buy's live values.
  After `pause_requested` the buy is still `ACTIVE` and may keep delivering.
  After `resume_requested` it is still `PAUSED`.
* `pendingChange.status` is the status the buy moves to once the seller
  confirms, and a pause also carries `pendingChange.pauseRequestedAt`.
* Read the buy again to see the change take effect. Do not tell the buyer the
  pause or resume is done until it has.

To cancel one media buy without changing its campaign or sibling buys, call
`save_media_buy` with `mediaBuyId`, `isCanceled: true`, and an `idempotencyKey`.
Send `isCanceled` on its own. A canceled buy returns `unchanged` when called
again; `isCanceled: false` is refused because cancellation cannot be reversed.
Some sellers or operator-controlled buys must confirm cancellation, so the
response can say that the request is not immediate and report a non-terminal
new status until confirmation arrives.

## 5a. Review the draft before going live

When the host renders MCP Apps, `open_campaign_receipt` opens **Review & go
live** for one draft campaign: its budget and flight, the staged media buys
with their budget split, why each buy is not live yet, and the readiness
blockers still standing between the draft and launch (a creative that is not
ready, or no media buys staged). The tool returns the shared MCP App directive,
a compact text summary of the same facts, and the projected receipt in
`structuredContent.receipt`. It reads only: going live remains the explicit
`save_campaign` step below.

```json theme={null}
{
  "name": "open_campaign_receipt",
  "arguments": { "campaignId": "cmp_987654321" }
}
```

The receipt covers draft campaigns only. For a campaign that has already gone
live, `open_campaigns_page` with the same `campaignId` opens its record
instead. The headless equivalent is
`get({kind: "campaign", id, include: ["mediaBuys"]})`, whose `mode: "review"`
workspace carries the same `readiness.blockers`.

## 6. Launch explicitly

Launch is a two-call update to an existing campaign, not part of campaign
creation. First preview — this call launches nothing:

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

The response is `action: "pending_confirmation"` with `campaign.revision` and,
under `launch`, the media buys that would go live and their combined budget.
Show it to the buyer. Only after they say yes, confirm with the previewed
revision:

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "expectedRevision": 1,
  "desiredPhase": "active",
  "confirmLaunch": true,
  "idempotencyKey": "acme-autumn-launch-v1"
}
```

See [Launch a campaign with explicit confirmation](/v2/setup/v3/tool-reference#launch-a-campaign-with-explicit-confirmation)
for the full preview shape.

Do not combine launch with pause or archive in the same call. A launch can
partially write downstream execution state even when no media buy activates;
read the structured error, fix the cause, re-read the campaign revision, and
retry deliberately.

## 7. Query campaign delivery

Use `get_delivery` with `report: "campaign_delivery"`. Supply either an explicit
UTC date range of at most 90 inclusive days or `range: { "lifetime": true }`
for everything from the campaign's earliest reported delivery to today, and
choose only the metrics and dimensions needed by the caller:

```json theme={null}
{
  "report": "campaign_delivery",
  "metrics": ["impressions", "clicks", "spend", "ctr"],
  "dimensions": ["date", "campaign", "media_buy", "package"],
  "range": {
    "startDate": "2026-09-01",
    "endDate": "2026-09-30"
  },
  "filters": {
    "campaignId": "CAMPAIGN_ID"
  },
  "limit": 25
}
```

For the whole life of a campaign, grouped by the seller it ran with:

```json theme={null}
{
  "report": "campaign_delivery",
  "metrics": ["impressions", "spend"],
  "dimensions": ["seller", "sales_agent"],
  "range": { "lifetime": true },
  "filters": { "campaignId": "CAMPAIGN_ID" }
}
```

Metric IDs that AdCP defines use AdCP's names, such as `completed_views`,
`conversion_value`, and `completion_rate`, both in `metrics` and as the keys of
each row's and the totals' metrics.

The response echoes the resolved window as `period` and always carries
`totals`: the same rollup as the rows, over every matched row rather than the
page (`totals.rowsIncluded` equals `page.total`). When matched rows span more
than one currency, `totals.currency` is null and money metrics are
`unavailable` with `reason: "currency_unavailable"`; counts and unitless rates
still total. Nothing is FX-converted.

`seller` is the storefront the buy was placed with and `sales_agent` the AdCP
sales agent behind it, both resolved from the products on the buy. A row whose
seller cannot be attributed keeps `seller: null`, groups with its peers, and
still counts in `totals`; it is never folded into another seller or dropped.

Filter by `advertiserId`, `campaignId`, `channelGroupId`, `mediaBuyId`, or
`packageId`. `channelGroupId` is applied before the report is bounded, so rows
from other Campaign groups cannot displace matching rows.
`packageId` requires a bounded date range (`startDate` and `endDate`, at most
90 days) and cannot be combined with `range: { "lifetime": true }` — the
reporting operation fetches all campaign data before filtering by package, so
lifetime package filtering is rejected before dispatch. An integrated Media
Company must name at least an advertiser, campaign, or media buy; the server
re-proves that scope against its sandbox advertiser and exact own Storefront
before querying. Advertiser-wide integrated queries also fail closed if any
current buy under that advertiser has wider supply.

Rows preserve the Buyer reporting denomination: `spend`, `ecpm`, `cpc`, and
`cpa` are gross and fee-inclusive where the underlying buy has pinned terms.
The response names its currency and reports numeric zero as available. A null
derived rate remains unavailable when its denominator or conversion signal is
absent.

This projection is seller-reported delivery viewed through the Buyer hierarchy;
it is not Buyer measurement. The V2 compatibility source does not expose
ordered revision evidence, so V3 does not infer `SNAPSHOT` or `OFFICIAL`
finality or billing eligibility, and `finality.revision` is `null`. When a
revision is present, it is an AdCP `ReportingRevision` with one added field,
`received_at`: when Apostra received that revision. Follow `nextCursor`
without changing the query when `page.truncated` is true.

### Read the connected provider live

When the current provider response matters more than stored aggregation, use
the same tool with `report: "live_campaign_delivery"`, one exact `campaignId`,
and a bounded range or `range: { "lifetime": true }`. Do not pass metrics,
dimensions, other filters, or a cursor. The existing `delivery` field retains
the provider's full response for current callers. SDK-defined fields are
validated and the whole response is bounded by the tool's response limit, but
provider extensions remain opaque.
Use the new `deliverySummary` field for a stable typed result: identity,
currency, reporting period, status, finality, aggregate metrics, media-buy
totals, package summaries, and paging and truncation signals. If adding the
summary would exceed the response limit, `deliverySummary` is omitted and the
existing `delivery` response remains available. The summary keeps the AdCP
`get_media_buy_delivery` response's own field names and places: aggregate
totals, media-buy totals, and package summaries carry their delivery metrics
(`impressions`, `spend`, and so on) directly on each object.
The summary includes up to 250 metric aggregates, 100 media buys, and 100
packages per media buy. Its count and truncation fields
(`metric_aggregates_truncated`, `by_package_truncated`,
`media_buy_deliveries_truncated` and their `_total_count` fields) are the only
fields it adds to AdCP's objects; they identify locally omitted items
separately from provider pagination.
Package summaries keep AdCP's breakdown completeness fields where provided:
`by_<kind>_truncated`, `by_<kind>_suppressed`, and `by_<kind>_pagination`.
Package finality retains its measurement window and superseded window; reach
retains its unit and window, and rates retain their pricing model. Detailed geography, creative, device, window subseries, and
other breakdown rows stay outside `deliverySummary`. The summary also omits
provider credentials, free-text provider messages, opaque extensions, and raw
reporting rows; those provider fields may still appear in the legacy `delivery`
field and its text rendering. Qualified metric aggregates retain their
scope, metric ID, numeric value, and compact qualifier; vendor identity is
limited to domain and brand ID. It marks its authority as `live_provider` and
does not claim reporting-pipeline finality or billing eligibility.

## 8. Save uploaded assets as a creative

Select the owning advertiser before preparing the upload. Only a finalized JPEG
or PNG `scope3-asset://` reference bound to that advertiser can be passed
verbatim as `sourceAssetRef` to the existing `save_creative` tool, together with
the matching `advertiserId` or `campaignId`. If no advertiser was selected
before preparation, keep using the reference through the existing
account-scoped delivery flow.

For an eligible reference, use the existing creative noun; there is no separate
adoption tool:

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

The upload and campaign must resolve to the same customer and advertiser, and
the authenticated principal must own the upload. Prepared, expired,
cross-customer, and cross-advertiser references are refused. `sourceAssetRef`
cannot be combined with `assets`; its JPEG or PNG media supplies the canonical
`image` format identity.

The private source is temporary. Saving copies its verified bytes into the
governed creative store, so the Creative remains usable after the upload
source expires. The stored manifest contains the governed asset and a one-way
fingerprint only—not the source reference, private path, or a signed URL.

When you include `campaignId`, the server creates the advertiser-scoped
Creative first and then applies the existing idempotent campaign membership.
Retry with the same reference and name if a response is interrupted; the retry
returns the same Creative and finishes a missing attachment. Use
`advertiserId` instead to save it without campaign membership.

For an advertiser Creative with multiple uploaded assets or a promoted MP4,
first search `creative_format` with the advertiser and selected product. Pass
the returned opaque format `id` as `creativeFormatId`, then bind each durable
asset `assetId` to its declared slot in `sourceAssets`. This keeps the Creative
in the advertiser Library without requiring a campaign or contacting a
provider. Ordinary `assets` are unbound and cannot satisfy those format slots:

```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"
    }
  ]
}
```

The signed format is account- and advertiser-bound. The server revalidates its
seller, product route, option, and declaration before reading an asset or
writing the Creative. If `formatKind` or `formatOptionRef` is also supplied, it
must match the selected format. The MP4 must have reached `promoted`, and all
sources must resolve to the same customer, principal, and advertiser. A
successful save confirms the canonical advertiser Creative. For a new Creative
that should be attached immediately, use `campaignId`, `formatKind`, and the
campaign product's exact `formatOptionRef` instead of `advertiserId` and
`creativeFormatId`. The save returns `providerContacted: false`; video
destination delivery remains deferred. Do not claim delivery until later sync
and exact provider readback prove it.

## 9. Author a social creative

A social creative is authored copy, not an uploaded file. `save_creative`
accepts a `social` block and stores each field in the standard AdCP text and
URL slots, so the creative round-trips through `save`, `get`, and `search`
without a preview or transcode step.

```json theme={null}
{
  "campaignId": "CAMPAIGN_ID",
  "name": "Autumn arrivals",
  "formatKind": "image",
  "clickUrl": "https://example.com/autumn",
  "social": {
    "headline": "Autumn arrivals",
    "body": "New season, new looks.",
    "callToAction": "Shop now",
    "displayName": "Acme"
  }
}
```

| Input | Slot it writes | Notes |
| - | - | - |
| `headline` | `headline` | Up to 255 characters |
| `body` | `body` | Primary text, up to 5000 characters |
| `description` | `description` | Secondary description, up to 1000 characters |
| `callToAction` | `call_to_action` | Label, up to 60 characters |
| `displayName` | `display_name` | Brand or page display name |
| `clickUrl` | `click_url` | The click destination; top-level, not inside `social` |
| `components[]` | the `slot` you name | `{ "slot": "ad_text", "text": "..." }` or `{ "slot": "landing_page", "url": "..." }` |

Platforms name the same authored field differently (TikTok uses `display_name`
and `ad_text`, Meta uses `primary_text`). When a creative is pinned to a
platform format, the format declaration decides which slot ids are valid: the
save is refused with the list of declared slots if a slot is not declared, and
per-slot length limits apply. Use `components` to write those platform-native
slots. Page or profile identity (a Facebook Page, an Instagram account) is not a
`save_creative` field; a format that declares it as a slot takes it through
`components`.

Social copy is content but carries no format identity, so a new creative still
needs `formatKind` (for example `image` or `video_hosted`) or a media asset.
On update (`creativeId` + `campaignId`), each slot named in `social` replaces
the existing value on that slot and unnamed slots are left unchanged.

### Read a creative in full

`get` with `kind: "creative"`, `sourceId` (the campaign ID), and `id` returns
the complete record. Every field `save_creative` also accepts comes back under
the same name and nesting, so a read can be edited and saved back:

| Section | Contents |
| - | - |
| `scope` | `campaign` or `advertiser`, with `campaignId` and `advertiserId` beside it |
| `formatKind` | Canonical format kind |
| `formatParams` | Canonical format parameters, when the creative was saved with them |
| `formatOptionRef` | The scoped format option when pinned, in AdCP's shape: `scope`, `format_option_id`, and `publisher_domain` |
| `format` | The stored `mediaKind` and `requiresUpgrade` |
| `social` | Typed copy read from the slots it writes (`headline`, `body`, ...), and every other slot verbatim in `components`, including platform-native slots such as `primary_text` |
| `clickUrl` | The click destination, when the creative has one clickthrough slot (`click_url`, or the clickthrough slot its format declares, such as `landing_page`); a creative with several clickthrough slots lists them all in `components` |
| `assets` | Stored media metadata: type, MIME type, slot, dimensions, duration, size, primary flag |
| `lifecycle` | `isArchived` (always `false` on a successful read), `requiresUpgrade`, sync state |
| `campaignIds` | Every campaign the creative runs on, with `campaignId` and, on advertiser-scoped lists, `reuseCount` beside it |
| `labels` | Dimension values, keyed by dimension key; tags are `labels.tags` |

`save_creative` takes tags only as `labels.tags`, the same place the read
returns them; see [Dimensions](/v2/object-guides/dimension) for label values.

Format identity is canonical only: a creative read never carries a legacy
`agent_url`. A creative without a canonical `formatKind` reports
`requiresUpgrade: true` and cannot be assigned to a new media buy until it is
upgraded through the v2 API.

### Filter creative search

`search` with `kind: "creative"` scopes by `filter.campaignId` or
`filter.advertiserId`. An advertiser-scoped search narrows with `formatKind`,
`assetType` (media kind, for example `IMAGE` or `VIDEO`), `role` (`evergreen`
or `reference`), `source` (`uploaded`, `generated`, `connected`), and
`promoted`. A campaign-scoped search supports `query` only, and the narrowing
filters are refused under campaign scope rather than silently ignored. Each row reports
`formatKind`, `mediaKind`, `assetCount`, and `requiresUpgrade`; the text block
repeats them for text-only hosts. Pagination is the opaque `cursor` from the
previous page. Filters by readiness state, archive state, or media-buy
assignment are not available: archived creatives are not listable, and an
`isArchived: true` filter is refused rather than returning the active list.

### Archive a creative

`save_creative` with `isArchived: true`, `creativeId`, and `advertiserId`
removes the creative from every campaign and frees its name, so a later create
under the same name is a new creative rather than a dedupe hit. Archiving is
permanent: `isArchived: false` is refused, an archived creative reads as
`NOT_FOUND`, and there is no `allowArchived` read for creatives.

## Interactive buyer Pages

Three buyer Pages have fixed v3 owners. Each owner binds one MCP App resource
in its tool descriptor, so a host that renders MCP Apps opens the same Page
from Murph, Claude, or ChatGPT; a host that does not render them receives the
text summary. The Pages self-fetch their data, so none of these launchers puts
the list into model context — use `search` and `get` for text answers.

| Tool | Page | Arguments |
| - | - | - |
| `open_advertisers_page` | Advertisers — every advertiser with campaign and draft counts and one next action each | none |
| `open_campaigns_page` | Campaigns — status, flight, and budget per campaign; open one for its media buys and creatives | optional `advertiserId` (scope) and `campaignId` (focus) |
| `open_campaign_receipt` | Review & go live — one draft campaign's plan, staged media buys, and readiness blockers | required `campaignId` of a draft campaign |

The compatibility `open_page` enum never lists these Pages; the owner tool is
their portable contract. Task pages:
[Open Advertisers](/v2/buyer/advertisers/tasks/open-advertisers-page),
[Open Campaigns](/v2/buyer/campaigns/tasks/open-campaigns-page), and
[Open Review & go live](/v2/buyer/campaigns/tasks/open-campaign-receipt).

## Lifecycle operations

* `isPaused: true` pauses an active campaign; `false` reactivates it.
* `isArchived: true` archives it from default lists without changing its phase,
  pause state, media buys, or budget commitment. It is refused while any
  executable or unsettled media buy remains; cancel or settle each named buy
  first, then archive the campaign.
* `isArchived: false` restores an archived campaign with the same phase and
  pause state it had when archived. Send it alone, then re-read the campaign
  before making further changes. Legacy rows whose status was overwritten to
  `ARCHIVED` have no recoverable earlier phase: they read as archived with
  `phase: "completed"`, including in an archived completed-phase search, and
  cannot be restored; create a new campaign instead.
* `desiredPhase: "canceled"` requests cancellation of the campaign and every
  nonterminal media buy. Send it alone with the current `expectedRevision`.
  Cancellation is asynchronous: a requested cancellation is `canceled`,
  `cancel_pending`, or `cancel_failed`; a buy that was already terminal keeps
  its observed terminal phase. The campaign stays `ending` until every buy is
  terminal. Retry with the latest revision to re-drive pending or failed buys
  without re-ending confirmed ones. This remains separate from archive and
  does not archive the campaign.
* `save_creative` with `isArchived: true` archives a creative permanently and
  frees its name; creative unarchive does not exist, so `isArchived: false` is
  refused (see [Author a social creative](#8-author-a-social-creative)).
* A tracked campaign is read-only until it is adopted or duplicated through
  the existing v2 workflow.
* `autonomy` fields are accepted for forward compatibility but are not
  persisted yet.

See [Preview limitations](/v2/setup/v3/limitations) before replacing a v2 buyer
integration.


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