Skip to main content
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 names it with accountId like any other call.

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 Apostra, product discovery has 30 seconds and the runtime reserves a further five seconds to receive and persist the result, so the continuation ends within 35 seconds. The number of dispatches depends on the request’s wait budget; it is not a fixed maximum. The runtime 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, Apostra 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. Every buyer account is its own AdCP operating unit, so the call takes no scope: the unit code is generated from the account name, or you can pass a short one as operator_unit ({ "id": "rce" }).
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. Confirming the commercial operator does not add users, change membership, or change the login organization. See Buyer setup and go-live for operating-unit codes and identity-locking rules.

1. Create an advertiser

Creating requires name and brand. When primaryCurrency or preferredTimezone is omitted, the advertiser gets the one its brandCountries share: ["GB"] gives GBP and Europe/London, and ["FR", "DE"] gives EUR. A country with several time zones uses its main one where one clearly dominates (Spain uses Europe/Madrid); a country with no main zone, such as the United States, has no shared zone. With no countries, currency defaults to 'USD' and the time zone to UTC. Pass any ISO 4217 code or IANA time zone to override, or update either later while the advertiser is unlocked. When name or brand is missing, or the countries don’t share a currency or time zone that was omitted, 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. If the buyer skips or defers the reporting time zone question, retry save_advertiser with "preferredTimezone": "UTC" explicitly and keep the other create fields. Never guess a time zone from the brand’s location, countries, or model knowledge. Tell the buyer that reporting will use UTC and they can update it later while reportingTimezoneLocked is false. New advertisers return reportingTimezoneLocked: false; check the current value before promising a later update. Omitting preferredTimezone again for a country with no shared zone returns needs_input again. 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.
Retain the returned advertiserId. To update, send it with only the fields to change. Do not send sandbox on an update. A hosted media partner’s products appear for an advertiser only once the advertiser has an active account with that media partner (why). Apostra sets that account up automatically once all of these are in place:
  • The buyer account has a confirmed operator and an operating unit (operator_unit), saved with save_buyer_operator. get_status reports the unit as operatorIdentity.operator_unit; while it is null, no account is opened for any of the buyer’s advertisers (details).
  • The advertiser has its brand and currency, which save_advertiser sets on create.
  • The advertiser’s markets (brandCountries) and channels (channels) are both answered. An empty list is an answer: [] means All markets or All channels. Leaving brandCountries out on create means All markets. Leaving channels out leaves it unanswered, and no account is set up for the advertiser until you send it.

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. When the brief states hard targeting that has a structured field, such as an age range, also set it in targetingOverlay once the buyer confirms the exact values; see Targeting stated in the brief.
Creation does not launch. Retain the returned campaignId and revision. Keep the campaign’s advertiser while launching its media buys. A campaign with no advertiser cannot launch a media buy; the launch stops before a request is sent to a seller.

Set a valid flight date

Use ISO 8601 date-times with an offset for flight.startAt and flight.endAt. The date must exist in its stated month. For example, September has no 31st day and February has no 30th day. A valid offset is preserved, so 2028-02-29T00:30:00+01:00 is valid even though it falls on the previous UTC day. Immediately before resolving a relative flight date such as “today” or “next week”, call get_status and use that response’s serverTime in UTC. Do not reuse a value from an earlier turn. Flight-bearing save_campaign and save_media_buy responses also include serverTime as a receipt, but it is not the clock for a later date calculation. A campaign start up to five minutes before the freshly read time is accepted to allow for clock skew between the caller and server; anything earlier is refused. A specific media-buy start must be on a later UTC day; use "asap" when the buyer wants to start immediately. If either tool returns VALIDATION_ERROR with field: "flight.startAt" or field: "flight.endAt", correct that date and send a new call. Nothing is saved for a rejected calendar date. 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 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:
After:
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. Each product’s targeting must be something the product offers, or the buy is refused; see Targeting each product offers. demographics remains discovery and readback only while seller transport is on AdCP 3.1. save_media_buy rejects it before contacting a seller.

Update media-buy targeting

You can update products[].targetingOverlay on a DRAFT media buy with save_media_buy, or on a live buy whose seller takes the change (see Change a live media buy). The update can only narrow the campaign targeting. Omit a field to preserve its buy-level value; send null to remove that buy-level override, so the campaign value applies again when the seller receives the update. A buy that has left draft and whose seller does not take the change, a value outside the campaign constraints, or a field the seller cannot update is refused before any change is saved. On updates, geo_countries and geo_countries_exclude accept ISO 3166-1 alpha-2 codes only. geo_regions and geo_regions_exclude accept ISO 3166-2 codes only. Names are display labels, never update input. Creation continues to accept codes or names and resolves names to canonical codes. 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. 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.
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 set of eligible sellers 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: Send sellerAccountIds, the sellers’ Account ids, from the sellerAccountId field on seller reads. A call that still sends sellerIds is refused with an error that names sellerAccountIds. See Naming a seller by Account id. When you save a campaign with a seller list, send sellerAccountIds, the sellers’ Account ids. A campaign read returns the same list as sellerAccountIds, in the same order, so write it back unchanged. An unknown or inaccessible seller is rejected before the campaign is saved. It is never silently removed from the campaign or treated as permission to broadcast. When sellerAccountIds is supplied, any requested seller outside the campaign list, outside an authorized seller scope, or not currently eligible returns CAMPAIGN_SELLERS_INELIGIBLE with seller Account ids and contacts nobody. Existing calls that supply sellerAccountIds now narrow the round and never reach sellers outside that scope. Without sellerAccountIds, 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 sellerAccountIds and use a campaign with no seller list, then set confirmBroadcast: true. Existing callers that supply sellerAccountIds now narrow the round; they do not broadcast. The response’s appliedCohort shows whether the round used the requested sellers, the campaign seller list, the authorized seller scope, or a confirmed broadcast, along with the bounded sellerAccountIds contacted. Channel groups narrow the round. When the campaign has 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. Who a broadcast round asks. A broadcast round asks the eligible sellers that are on for the campaign’s advertiser and where the advertiser has an active account. Sellers that are off for the advertiser, or where its account is still being set up or needs attention, are outside the round: they are counted, not listed, and a round with no seller left says how many of each there are. A seller that is on for the advertiser but cannot be booked right now is not asked, and the response names it in skippedSellers with the reason not_transactable and a skipCode: A later round considers these sellers again once they can be booked, and the campaign’s channel groups still decide whether they are asked. If no seller on for the advertiser can be booked, the round ends failed with cohortError.code NO_ELIGIBLE_SELLERS and the same list. Some sellers cannot receive proposals at all. A seller whose platform sells only fixed wholesale products, with no brief or RFP path, declares this on its seller read as supportsProposals: false (see Seller and product filters). Naming only wholesale-only sellers in sellerIds refuses the round with SELLER_WHOLESALE_ONLY, naming the sellers and pointing at search(kind: "wholesale_product") and save_media_buy as the working path. Naming a mix of sellers, a campaign seller list, or a broadcast round instead skips the wholesale-only ones and proceeds with the rest, and the response names them in skippedSellers with the reason wholesale_only. 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.
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). The campaign budget is not sent with this proposal round. A later seller-optimized Meta buy sends its own total, or the saved campaign total when it gives none.
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 listed on Apostra for this no-spend workflow.
expectedSellerAccountId is the seller’s Account id, not an internal customer ID or a Storefront 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 buyer’s complete eligible cohort. Omit it when broad discovery across Apostra 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 45-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, min_spend_target_percentage, max_spend_percentage, pricing_option_id, and rationale. Each split field is there only when the seller stated it:
  • In a fixed proposal, each line has its allocation_percentage.
  • In a seller-optimized proposal, the seller splits the budget itself, so a line has no percentage, only the spend range the seller set (min_spend_target_percentage, max_spend_percentage).
  • A seller on AdCP 3.2 prices each purchase with its own budget, which the line carries; its allocation_percentage is present only when the seller stated every share.
budgetAllocation is the seller’s AdCP BudgetAllocation: fixed, or seller_optimized with the goals the seller optimizes the split to. It is absent when the seller did not say. Each row of the products include carries the product’s snapshot: the seller’s AdCP Product as it was when the proposal was recorded, with storefront_id, sales_agent_id and inventory_source_id naming the route it was found through. It holds the product fields kept with the proposal: product_id, name, pricing_options, delivery_type, measurement_terms, publisher_properties and the product’s optimization declaration (for example metric_optimization), not the whole product. The product’s overlay_support and demographic_targeting are on the row as overlaySupport and demographicTargeting. publisher_properties names the publisher properties the product covers, as AdCP publisher property selectors: a publisher_domain (or a publisher_domains list) with selection_type all, or with the property_ids or property_tags it selects. Look up each property in the publisher’s adagents.json. It is kept only when the seller’s selectors match the AdCP schema when the proposal is saved, and only while the proposal’s products together stay within 256 KiB of selectors, taken in allocation order; otherwise the snapshot has no publisher_properties. A list is never cut short, and the rest of the snapshot is kept either way. The text answer lists them for each product, up to five selectors, eight publisher domains and five ids or tags each. 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.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. A buyer’s search of kind: "proposal" for a campaign and get of one proposal add three more reads the server decides, so every caller gets the same answer:
  • recommendation, on the search result’s objects: {proposalId, basis} names the one quoted proposal the server recommends booking. It reads the campaign’s quoted proposals (up to 100), not only the page returned. results is exactly the page you asked for, so the recommended proposal may not be in it; read it with get (kind: "proposal", its proposalId). A proposal is eligible only when it is quoted, can be accepted (a seller-optimized proposal cannot yet), its seller met your asked target (commitment.meetsAskedTarget is true) and its commitment is guaranteed or best_effort. The strongest commitment wins, then the lowest answered cost, then the earlier quote. basis is guarantees_goal for a guarantee and meets_goal_best_effort for a best-effort answer. The field is absent when no proposal qualifies, and it never recommends a proposal with no structured answer.
  • estimatedVolume, on a proposal: {metric, low?, mid?, high?}, what the seller’s own stored forecast expects of the asked goal’s metric. It is read only when the forecast states it plainly: a spend-curve forecast with one point at the budget the proposal books at (or, when the proposal states no budget, one budgeted point) carrying the metric. A point with no budget is the seller’s total available inventory, so it is never read. Points are never added together and no volume is worked out from a price, so it is absent when the forecast does not say.
  • sellerRecord, on a proposal: the seller’s record against its commitments on the goal the proposal answers, {objective, commitmentRecordSource, cells}. The server matches the proposal’s goal to the record’s own objective (for an event goal, its first event type), so a caller never has to. It is returned only to an account in the seller record pilot, with the same disclosure as the seller read’s commitmentRecord: your own buys with exact counts, or a co-op member’s pooled, rounded snapshot. cells is empty when the seller has no history on the goal yet, which is a fact; the field is absent when the proposal has no goal to match or your account is outside the pilot.
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 explains each basis. Goal-seeking campaigns explains the goal statement these answers respond to and who carries the risk under each commitment kind.

Accept a quoted proposal

If a product in the proposal has auction pricing, the seller leaves the price to your bid, so the accept needs one. Send products with one entry per auction product: its productId from the proposal and a bidPrice in the campaign currency. Nothing else is accepted in products with fromProposalId, because the seller’s quote sets every other term. An accept without the bid is refused with a VALIDATION_ERROR that names the product, and no draft is created.
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. Each line is staged with the seller’s own budget when it has one, and otherwise with its allocation_percentage of the proposal’s recommended budget. A seller-optimized proposal (budgetAllocation.mode is seller_optimized) cannot be accepted yet: the call returns NOT_IMPLEMENTED and creates no draft. refine_proposals only records the buyer’s decision; it does not contact the seller or request a fixed split. No V3 tool can ask the seller for a fixed split. 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. If staging the draft fails, no media buy is linked and the same proposal stays quoted while it remains valid. Correct the reported staging problem, then retry the same proposal; do not request a replacement proposal round. A proposal past its seller validUntil or the 90-day addressability window is expired and cannot be retried.

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. The key is the search round’s productQueryId, not one you choose. Products from a wholesale_product search are staged the same way, with the search’s productQueryId. A productId is either the qualified id discovery returned or the seller’s own wh: id. Pass pricingOptionId for a product that offers more than one pricing option; without it, that product is refused with the list of option ids:
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.

Cap a media buy’s daily spend

Send AdCP’s daily_budget_cap when you stage a media buy from sellerAccountId and products to set a hard ceiling on what the buy spends in one day. It is a number in the buy’s currency. Send budget_cap_timezone, an IANA timezone such as America/New_York, to say where the cap’s day begins and ends; omit it to take the seller’s default. A timezone needs a cap.
The cap belongs to the media buy, not the campaign. The rules:
  • A buy’s cap is at most its total budget. A cap above the total is refused before anything is staged.
  • A campaign’s budget.dailyCap is a ceiling across all of its media buys. The buys’ caps add up to at most that ceiling, so each buy’s cap is smaller than the campaign’s. The campaign’s cap is never copied onto a buy as that buy’s cap. A buy whose cap would take the sum past the campaign’s cap is refused, naming both numbers. A buy with no cap adds nothing to the sum.
  • The cap is saved with the draft and goes to the seller when you launch. Like the buy’s other amounts, it is sent as the seller’s net amount, after the buy’s fees. A buy that settles in a different currency from the cap is refused at launch rather than sent in the wrong currency.
  • A seller that cannot apply a daily cap refuses the buy. It is never dropped silently, so a buy you launched with a cap either carries the cap at the seller or does not launch.
  • It is create-only for now. save_media_buy refuses daily_budget_cap and budget_cap_timezone with mediaBuyId (not implemented) and with fromProposalId (the seller’s proposal sets the buy’s terms).

5. Inspect staged work

List media buys under the campaign:
Or list every current buy for an advertiser across its campaigns, paged (limit up to 200; pass back nextCursor for the next page):
Each row reports its campaignId (when the buy belongs to a campaign), phase, pause state, flight, the seller (as sellerAccountId and sellerName), and the buy’s gross budget. sellerAccountId is the seller’s Account id, the one to send back. 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:
The base object carries campaignId, sellerAccountId, sellerName, budget, flight, and the why-visibility fields (pendingReason, cancellationPending, errorCode, forwardedAt, buyerReference). When a submitted change is not live yet, it also carries pendingChange; see A change that is not live yet. Includes add:

Provider readback and indicators

When a retained provider check exists (Meta and OpenAI Ads support it, once switched on for the seller account), the base media-buy object also returns readback. Its outcome is succeeded, failed, or in_progress, and checkedAt says when Interchange made that check. A successful outcome can also carry confirmedAt, the time on the provider value. A failed outcome keeps the last confirmed time separate, and sets stale: true when that value is more than 15 minutes old. failureReason: "connection_unavailable" means the provider connection needs attention; "unreachable" means the provider could not be reached. An in-progress result has no confirmed provider value. readback is omitted when no provider check has been retained. Reading the media buy does not start a check or turn an unknown result into an in-progress one. The same read can carry compact current indicators[]. This uses the AdCP indicator vocabulary: indicatorTypesEvaluated[] names the condition types the check covered, and indicatorsAsOf dates that evaluation. An empty indicators array means neither evaluated condition is current. Omitted indicators means no retained provider evaluation exists yet.
  • interchange.provider_drift groups unresolved budget, flight, creative, targeting, or name differences. Its ext.interchange.fields names the differing fields and financial marks a budget or flight difference.
  • interchange.discovered_object groups creative candidates that the provider reports but Interchange did not dispatch. Its ext.interchange.objectTypes and count describe the current candidates.
These are current attention signals, not a discrepancy history or an action. Use the normal media-buy workflow to make a change; this read never changes the provider. 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. operations is a compact default summary when Seller work exists. Add include: ["operations"] for the bounded recent list. Each row carries its durable operationId, kind, state, since timestamp, attempts, destination, and next action or a fenced blocked reason. The summary shows at most ten destinations, prioritized by unfinished work; when it is capped, truncated: true and totalDestinations tell you that more exist. A durable operation without a Seller route appears as unassigned: true. If the durable operation store cannot be read, the field returns status: "unavailable" — do not treat that as no work. 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.
  • name — rename the draft. A draft has not reached a seller yet, so the new name applies straight away.
  • 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 cannot be archived. Request its cancellation with save_media_buy({ mediaBuyId, isCanceled: true }), then archive it once the cancellation is terminal. The response remains truthful when seller or operator confirmation is still pending.
A completed buy stays as the delivery record. It cannot be archived or canceled, needs no action, and no longer delivers. Anything the draft cannot absorb is refused, never silently ignored. A product that is not already on the buy returns VALIDATION_ERROR on products. A different inventory source or seller route, changed Signal targeting, or restoring an archived buy returns NOT_IMPLEMENTED naming the field.

Change a live media buy

To see what a live buy can and cannot change, or to add a product to it, read Change a live media buy. Adding a product to a live buy, or changing the buy’s budget, is the seller’s decision. get with kind: "media_buy" returns liveChanges (addProducts, removeProducts, changeBudget, changeTargeting, changeFrequencyCap, changePacing), each accepted, conditional, needs_seller_approval or not_accepted, from what the seller declared for the buy. When the seller takes an add, save_media_buy with the buy’s mediaBuyId and products: [{ productId, pricingOptionId, budget }] sends it to the storefront the buy trades with. An add stands alone, so send nothing else in the same call. The result is action: "updated" once the seller has applied it, or action: "pending_approval" while the seller has taken it and not yet applied it. When the seller does not take the add, save_media_buy returns ACTION_NOT_ALLOWED and details.recovery is create_separate_media_buy. A budget change the seller does not take is refused the same way, with details.action: "update_budget", and nothing is sent. Removing a product from a live buy follows the same rule: liveChanges.removeProducts says whether the seller takes it. When it does, save_media_buy sends the removal to the seller as a package cancellation and returns the seller’s answer; the packages change only when the seller confirms. A seller that does not take it is refused with ACTION_NOT_ALLOWED and details.action: "remove_packages", and nothing is sent. It is allowed on a draft. Changing a line item’s targeting on a live buy follows the same rule. liveChanges.changeTargeting says whether the seller takes it. When it does, save_media_buy with products: [{ productId, lineItemRef, targetingOverlay }] sends the change to the line item’s open packages, on its own, and the result is action: "updated" or action: "pending_approval" as for an add. When the seller does not take it, save_media_buy returns ACTION_NOT_ALLOWED with details.action: "update_targeting". A frequency cap (targetingOverlay.frequency_cap) is its own change, decided by liveChanges.changeFrequencyCap; it is sent the same way and refused with details.action: "update_frequency_caps" when the seller does not take it. A line item’s pacing follows the same rule. liveChanges.changePacing says whether the seller takes it, save_media_buy with products: [{ productId, lineItemRef, pacing }] (even, asap or front_loaded) sends it to the line item’s open packages, and a seller that does not take it returns ACTION_NOT_ALLOWED with AdCP’s details.reason: "not_supported_on_buy". A seller-optimized buy does not take package pacing yet. Once a media buy has gone to the seller, save_media_buy can change its flight, its budget, and its name:
  • 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.
  • On a Meta buy created with budgetAllocation.mode seller_optimized, the top-level budget is the buy’s own total, whatever number of products it has, and products[].budget is refused. On a live buy the seller must take a budget change (liveChanges.changeBudget is not not_accepted), and a change may wait for the seller’s approval while the live buy keeps running. The total stays in the buy’s currency and must fit in the campaign’s remaining budget. Meta refuses a total below what the buy 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.
  • products[].bidPrice changes an auction line item’s bid on every package whose flight or pacing period is still open. Fixed-price products and bids outside the selected pricing option’s floor or ceiling are refused.
  • name renames the buy, using the AdCP media-buy name (1 to 255 characters; leading and trailing spaces are removed). Meta, Snap, TikTok, Pinterest, Google Ads, LinkedIn, Reddit and Spotify media buys can be renamed, and only after the seller confirms the new name, as described in Rename a live media buy. Renaming a campaign does not rename its media buys.
  • 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:
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. Its details.mutation_outcome says whether the change may have been applied; see When a seller does not apply a change. 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.

Rename a live media buy

A rename has to reach the seller. Apostra never shows a name the seller has not applied, so the call waits for the seller’s answer. Meta, Snap, TikTok, Pinterest, Google Ads, LinkedIn, Reddit and Spotify media buys can be renamed. A rename on any other media buy is refused before anything is written or sent: Amazon, OpenAI Ads, and other ad platforms, and sellers reached through a sales agent or a managed storefront. A buy that mixes a platform that can be renamed with any of these is refused too. The rename changes the name of the campaign on the ad platform. A few platforms add their own rules:
  • Google Ads refuses a name that another campaign in the same account already uses. Choose a different name.
  • LinkedIn renames the campaign; its campaign group keeps its name.
  • Reddit keeps the [AdCP …] marker Apostra adds to the end of the campaign name, and refuses a new name that itself ends with such a marker. A canceled Reddit buy cannot be changed at all.
  • Spotify may take up to a minute longer when Spotify rate-limits the change, because Apostra waits and sends it again.
  • action: "updated" with the new mediaBuy.name means the seller confirmed the rename.
  • action: "pending_approval" means the storefront reviews changes to live buys and the rename is waiting for its approval, as any other change does. The returned mediaBuy still shows the current name and carries pendingChange. Do not send the rename again or tell the buyer it is done.
  • An error with code CAPABILITY_NOT_SUPPORTED, field: "name", and details.mutation_outcome: "not_dispatched" means renaming is not supported for this buy yet, for example “Renaming isn’t supported for Amazon yet. No changes were applied.” details.unsupported_platforms names the ad platforms. Nothing in the call was applied, and retrying will not help until the platform supports renames.
  • An error with field: "name" and details.mutation_outcome of "not_dispatched" or "rolled_back" means the seller refused the change. The buy keeps its current name and nothing in the call was applied on the ad platform. When the seller says what to correct, for example a name the platform does not accept, the error has code VALIDATION_ERROR and the seller’s own message where the platform provides one. Otherwise details.per_source names the seller’s error code. Do not resend the same rename unchanged.
  • An error with field: "name" and details.mutation_outcome: "unknown" means Apostra could not verify that the seller applied the rename, for example because the seller timed out or failed. The buy keeps its current name in Apostra; the seller may or may not show the new name until the change is reconciled. Retry the rename later.
These two field: "name" errors appear only when the rename is the call’s only change. When the same call also changes the flight, budget, or a line item, the seller may have refused that change instead, so the error is the ordinary SERVICE_UNAVAILABLE described in Change a live media buy, with details.mutation_outcome and no field. Read the buy before resending any of the changes. To rename a buy:
name applies only to an existing media buy. Sending it while creating a draft or accepting a proposal returns VALIDATION_ERROR with field: "name". Send it on its own or with flight, budget, or products[]; it cannot be combined with isPaused, isCanceled, or isArchived.

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:

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. While an operator approval is pending, the buy stays live and get or search returns cancellationPending: true with pendingReason: "awaiting_cancellation_approval". Both fields disappear when the cancellation is approved or rejected; an approved cancellation then reads as canceled.

When a seller does not apply a change

When a change to a live media buy fails at its seller, the error says whether anything may have been applied there, so you know whether it is safe to send the change again. The error’s details.mutation_outcome is one of:
  • not_dispatched: nothing in the call was applied. Either Apostra stopped before sending it, or the seller refused the request itself (for example with VALIDATION_ERROR). Fix what the error names, then send the change again.
  • rolled_back: the change reached the seller and was undone. Nothing in the call is left applied.
  • unknown: the seller may or may not have applied the change, for example because it timed out, failed, holds the change for approval, or answered in a way that does not prove it refused. Read the buy before sending anything else.
details.per_source lists each failing source as { sourceId, code }: the source’s id and its error code, without the seller’s own wording. Other fields can still quote the seller: the error message, suggestion, and on a save_campaign pause or resume, details.reason and each details.mediaBuyResults[].error. An error with no mutation_outcome either involved no seller (for example a buy that was not in a state to change) or could not be classified. Treat an unclassified error as unknown and read the buy first. A cancel or archive whose connection to the seller failed, or whose result the seller could not confirm, is one of these: it returns a generic internal error with no details. These save_media_buy errors carry it:
  • a change to the flight, budget, line items, or name (see Rename a live media buy);
  • isPaused: true or isPaused: false;
  • isCanceled: true;
  • isArchived: true, when archiving needs a seller cancellation.
A save_campaign pause or resume that did not change every media buy also carries details.mutation_outcome for the whole call. It is not_dispatched or rolled_back only when no buy changed and every seller applied nothing; otherwise it is unknown. Each entry of details.mediaBuyResults that failed carries its own mutation_outcome, so you can tell which buys may have changed. Launching a campaign (desiredPhase: "active"), the per-buy results of canceling a campaign, and save_creative delivery to sellers do not report mutation_outcome yet. Read the media buy before retrying one of those.

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.
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:
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:
See 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:
For the whole life of a campaign, grouped by the seller it ran with:
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. Totals are not converted from one currency to another unless you send currency. Send currency (an uppercase ISO-4217 code Apostra’s daily rate store prices, such as "EUR") to read money on the rows and totals in that currency. Each day converts at that day’s own rate, and a row that spans days is converted day by day and then added up. A row with a day that has no rate keeps its own currency and values, and totals then reports money metrics as unavailable with reason: "fx_rate_unavailable". The response describes the conversion in conversion. Goal progress is not converted. See Converting into one currency. A buy’s spend is reported in its advertiser’s currency. When the seller reported a buy’s spend in another currency, Apostra has already converted it, and the response says so in currencyConversions, one entry per media buy behind the page’s rows. See Conversions already applied for each field. 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. When every media buy represented by a row has one canonical reporting revision, V3 returns its SNAPSHOT or OFFICIAL class, the revision (with Apostra’s received_at), and the revision’s data_through. freshness: "ledger_as_of" means that canonical evidence supplied those values. A row spanning absent or different revisions remains explicitly unavailable; Apostra never infers finality from a delivery date, a live read, or the presence of numbers. Billing eligibility remains separate and is not evaluated here. 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:
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:
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.
Each input is named for the AdCP slot it fills, so an agent that knows AdCP’s slot ids needs no translation table. social has no description field — AdCP’s slot vocabulary has no secondary-description equivalent, so the generic authoring surface doesn’t offer one. A format that declares its own description-like slot (platform-native formats only) takes it through components. 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: save_creative takes tags only as the labels entry with dimension: "tags", the same place the read returns them; see Dimensions 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. 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 Apostra, 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. The compatibility open_page enum never lists these Pages; the owner tool is their portable contract. Task pages: Open Advertisers, Open Campaigns, and Open Review & go live.

What sets a campaign’s phase

You do not set a campaign’s phase directly except to launch or cancel it. Apostra moves it from what the campaign’s media buys are doing, and a media buy’s state is the seller’s report. The phase is checked hourly:
  • active means at least one media buy under the campaign is running, paused, or waiting on its seller. A campaign with no flight dates has no end date to follow, so its media buys are the only evidence: it stays active while one is live and not otherwise.
  • draft is where an active campaign returns when every media buy under it has been cancelled, rejected, or has failed, or when it has none, and it has gone an hour without a change. Flight dates do not matter. The campaign’s readiness shows no_staged_buys. Stage a media buy and launch again with desiredPhase: "active".
  • completed means the campaign has ended. Two things end it:
    • The flight has ended and every media buy has finished (completed, cancelled, rejected, or failed) or was never launched.
    • Every media buy that is still running or paused is past the end its seller states for it (end_time in the seller’s own answer, not the end you booked), even if the seller still reports the buy active. Media buys that have not started or have finished no longer hold the campaign open. This applies to a campaign with no flight dates too.
    A flight end alone does not complete a campaign while the seller still reports a media buy running or paused and states no end for it, or states one that is still ahead. The campaign stays active, and a buy the seller keeps reporting active after its flight end reads attention_required (for example seller_still_active). Apostra never changes a media buy’s own state: the buy stays as the seller reports it, and only the campaign’s phase follows the seller’s stated end. A media buy carrying a reconciliation marker keeps its campaign from moving to completed, whatever end its seller states, and the v2 campaign list and summary report that campaign’s status as PENDING while the marker stands. A campaign that is already completed returns to active for a running buy with no stated end, for a running or paused buy whose stated end is still ahead, or for a running or paused buy that states an end and carries a reconciliation marker. A paused buy that states no end does not reopen a completed campaign, marker or not.

Lifecycle operations

  • isPaused: true pauses an active, unpaused campaign; false resumes an active, paused campaign. Pausing a draft or completed campaign, or resuming a campaign that is not paused, is refused with a conflict error.
  • 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" is irreversible: it cancels the campaign and every live media buy. Send it alone with the current expectedRevision. A guaranteed committed buy goes to operator approval and keeps delivering until approved. 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. To stop one buy without affecting its campaign or siblings, use save_media_buy with isCanceled: true, or isPaused: true to pause it.
  • 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).
  • 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 before replacing a v2 buyer integration.