Skip to main content
get_products is the canonical product door for buyer agents. Send one AdCP-shaped request to selected storefronts—or all storefronts connected to an advertiser—and receive one bounded canonical page as products[] plus optional sibling proposals[]. Every identity remains qualified by its originating storefront. Apostra can optionally evaluate valid seller proposals before returning them. You supply plain-language operating instructions. A managed model chooses accept, reject, or refine, can attach buyer-owned enrichment, and can rank the accepted cohort across sellers.
AdCP remains a one-buyer-to-one-seller protocol. Apostra is the many-to-one buyer layer: it fans out the canonical request, validates each seller independently, qualifies identities, and returns a bounded aggregate page. Sellers do not see one another or receive an Apostra-specific request.

One protocol request, marketplace scale

The scaling layer does four things without forking the protocol:
  • Bilateral calls stay canonical. Buying mode, brief, filters, proposals, and refine are ordinary AdCP semantics at each seller edge.
  • Identity survives aggregation. sf1: product IDs and sfp1: proposal IDs prevent cross-storefront collisions and round-trip into purchase.
  • Slow sellers do not erase fast sellers. Revisioned replacement snapshots expose seller progress while other agents remain pending. Candidates become selectable only after their complete seller/source identity is persisted.
  • Responses stay agent-sized. pagination.max_results bounds candidates; proposal graphs remain whole, and the cursor is frozen only when the execution is complete.

Which sellers are searched for an advertiser

Discovery for an advertiser searches only the media partners turned on for that advertiser: partners selected automatically for its markets and channels, partners included for the whole account, and partners turned On for that advertiser. Media partners that are off for the advertiser are not contacted and do not appear in storefront_results[]. They are outside the search, not failed sellers. When the request names no storefronts, ext.interchange.storefronts_not_searched lists the media partners left out for the advertiser: each one’s storefront_id, storefront_name and reason. NO_AUTOMATIC_MATCH means it does not match the advertiser’s markets and channels, ADVERTISER_DISABLED means it is turned off for the advertiser, ACCOUNT_ALWAYS_EXCLUDE means it is excluded for the whole account, and HARD_INELIGIBILITY means it cannot transact with you at all. At most 10 are listed; total counts every one. When you can turn any of them on, suggestion says how, once. The get_products text summary names them the same way. Each media partner appears once, even when it is reached through more than one sales agent. The list is reported only for an advertiser your account itself owns; for any other advertiser, such as one another organization delegated to yours, it is omitted rather than guessed. A media partner that is off also changes the rest of the response in these cases:
  • You named it. When the request names specific storefronts, or refines a product or proposal from one, a named media partner that is off returns a storefront_results[] row with status completed, no products, and an outcome of kind action_required with reason_code advertiser_activation. Its message says why it was not searched and its suggestion says where to turn it on. A partner turned off for the advertiser is turned on in the advertiser’s view of Media Partners (open the media partner, choose Controls, set Availability to On). A partner excluded for the whole account is changed in the account view, from Always exclude to Default or Always include.
  • No media partner is on. When none is turned on for the advertiser, discovery searches nothing and guidance says so, with the same path to turn one on.
  • Otherwise, an empty result’s guidance also reports how many media partners were left out because they are not turned on for the advertiser.
A media partner that cannot transact at all is reported as skipped when you named it; turning it on does not change that. A media partner that is still finishing go-live is different. Some managed ad servers need the media partner to pass a no-spend certification, such as a test write to the ad server, before it takes its first booking. Until it passes, the media partner is searched like any other: its products appear in results, and you can add them to a buy you are planning. Creating or executing a media buy with it is refused with ACCESS_DENIED and a message saying the media partner has not finished going live. There is nothing to turn on; the media partner finishes the certification on its side, and booking opens once it passes. Saved results follow the same rule. When a media partner is turned off for the advertiser after a search, reading that saved discovery no longer returns its products; an empty page’s guidance says how many saved products were removed for that reason. Adding one of its products to a buy, directly or by applying a proposal, is refused with ACCESS_DENIED and a message saying where to turn the media partner back on.

Read each seller result honestly

ext.interchange.storefront_results[].outcome is present when a seller was queried through its own agent, or when a seller you named was not searched. It is one buyer-safe terminal outcome: returned_products (with its count), no_matching_products, no_inventory_for_request, action_required (a named seller that is off for the advertiser, with its fix), skipped (with a safe reason), or failed (with a safe reason). A seller with no active wholesale inventory is no_inventory_for_request, not a failed seller and not a retry instruction. An empty seller result becomes no_matching_products only after that seller has completed without an error. A seller response with an error and no products is failed when no cached catalog can serve the request, even when the seller marks its task completed. An incomplete result remains incomplete and can carry a safe seller clarification instead. When one agent covers several sellers, outcome and filter_result are omitted because that agent-level result cannot be attributed honestly to an individual seller. Those rows carry product-derived counts and property_coverage[] only. Agent-level outcomes for that case are tracked in AI-10300. Returned products also produce property_coverage[]: each property domain and the number of returned products carrying it. Treat this as coverage of the seller response, not an exhaustive marketplace claim. Each seller row includes at most 12 property domains, with property_coverage_total and property_coverage_truncated when the buyer needs to recognise an abbreviated list. When a seller reports filter accounting or unfinished work, filter_result says whether no products matched, filters excluded every candidate, or the result is partial. Seller diagnostics and operational details are not exposed here. The advertiser’s property exclusion list is applied after seller products are returned. ext.interchange.excluded_product_count is the total number removed, and each storefront row exposes its own storefront_results[].excluded_product_count. Each seller row also has a bounded excluded_products[] list with the removed product ID, name, and the matching domain or domains. These domains are the intersection with the buyer’s own exclusion list, never the rest of that list. If excluded_products_truncated is true, use the count as the complete total and describe the listed products as a bounded sample. If exclusion removes every completed candidate, read the response guidance and report that N products from M sellers were removed because they included properties on the advertiser’s exclusion list. If a legacy candidate has no seller identity, the total still includes it but no seller row does; report the removed-product count without inventing a seller count. Do not call either outcome “no matching inventory”.
get_products does not stop after the first 10 storefronts or choose a final cohort by response speed. When no storefront selector is supplied (or storefronts: "all_connected" is explicit), Apostra resolves every storefront in the buyer’s eligible discovery scope and queries each backing sales agent once. An ordinary provisional revision contains only sellers that have answered so far and returns pagination.has_more: false with no pagination.cursor; poll the same execution_id with since_revision until results_complete: true. Only then follow pagination.cursor until has_more: false to consume the complete candidate set. This enforces the stable-cursor contract: incomplete ordinary results are replacement snapshots, not pages to append. pagination.max_results limits each completed response page, not the seller fan-out. The legacy Discover Products groupLimit and groupOffset fields do not apply to this surface.
If the buyer’s absolute wait budget ends while Apostra is still assembling the canonical page (for example, managed evaluation is still running), Apostra returns the latest persisted replacement snapshot when one exists. That page keeps the products and storefront results from sellers that already answered, sets results_complete: false, and names the remaining sellers as pending. The requested processing mode is degraded: evaluation requests mark ext.interchange.evaluation.evaluated: false, while screening requests mark ext.interchange.screening.evaluated: false; those candidates were passed through without the requested instructions. The usual max_results and its server-marked deadline-snapshot pagination cursor still apply to that replacement page. If no seller snapshot has landed yet, the same incomplete response is an empty page and says so without claiming that a seller failed to answer. For a pure pass-through storefront, a country filter also narrows the storefront’s internal source fan-out after the staged routing flag is enabled. Apostra sends the request only to an active source whose own standard primary_channels and primary_countries match the requested coverage. A source with missing or malformed primary coverage is unknown and excluded from filtered fan-out after activation; before activation, legacy fan-out is unchanged. Declarations from two different sources are never combined to manufacture a match. An unfiltered request remains storefront-wide. Apostra rejects ambiguous seller-specific controls such as conditional catalog versions on the aggregate surface. Query that seller’s AdCP endpoint directly when you need a seller-specific conditional request.

Use canonical filter names

Apostra rejects known cross-operation fields inside filters before contacting any seller. Use filters.countries to narrow products by country coverage. Put delivery targeting such as geo_countries under targeting_overlay, not filters. excluded_content_categories is not a supported get_products filter and must be omitted. Canonical filter fields from the AdCP versions supported by Apostra remain available. Put seller-specific filter criteria under a vendor namespace in filters.ext, for example filters.ext.example_seller. These unsupported fields return a validation error that names the field and how to correct it.

Canonical options from older seller formats

Buyers

When a seller still uses named legacy formats, get_products can return the seller’s explicit mapping as a URL-free canonical format_options[] entry. Buyer integrations do not need to change: existing canonical options and legacy format_ids[] keep their current shapes. When a buyer selects one of these canonical options, Apostra retains the seller’s exact publisher-scoped route for later create_media_buy, update_media_buy, and assigned sync_creatives delivery. The route is never reconstructed from a similar format kind or ID. If the exact seller mapping is missing, malformed, or inconsistent with the returned option, delivery fails closed and the buyer should refresh products before retrying.

Sellers

Sellers opt in one format at a time through the catalog returned by list_creative_formats. Add a canonical declaration to the legacy row. If the option needs canonical parameters, include canonical_parameters on the same row and use the same kind:
Dimensions and duration in the canonical declaration must agree with the legacy format reference. Apostra ignores missing, invalid, conflicting, ambiguous, or unsupported mappings. If a returned product’s older format remains unresolved, the seller result includes details in projection.diagnostics. Apostra never guesses a canonical kind from a legacy ID or asset list, and one seller’s mapping cannot apply to another seller. Products may publish canonical format_options[] directly or retain exact legacy references from a shared format catalog. Use the catalog mapping to replace seller-specific formats that Apostra cannot interpret.

Product-format resolvability

When canonical-format compliance is enabled for a storefront, Apostra surfaces a seller-visible blocker only when a format cannot be mapped to an interpretable declaration. Exact legacy {agent_url, id} references remain supported without a sunset, for both existing and newly connected sources. Direct URL-free format_options[] are accepted but are not required yet. A source with no persisted product-format evidence receives an evidence-pending blocker until its initial catalog sync records declarations. Catalog age is a separate freshness concern: an interpretable declaration does not become noncompliant when its cache entry ages. An unresolved declaration remains withheld until corrected. For a sales agent on the Agent-supplied path, each live get_products response is a brief-specific observation, not a complete catalog snapshot. The newest response for each public or buyer-account context replaces that context’s previous compliance observation, so products omitted from a later brief do not keep the source withheld. A buyer that tries to purchase a previously returned product still has that exact product’s declaration checked before the buy is forwarded. This policy audits active external third-party sales-agent sources; managed and modular inventory sources use their own readiness contracts. A canonical-format blocker withholds the affected source from discovery and media-buy admission until the seller replaces the unresolved custom reference with an exact shared-catalog reference or an interpretable custom canonical declaration. Other healthy sources in the storefront continue selling. If no other source can serve, the storefront remains transaction-ineligible.

Choose get_products or Discover Products

Both surfaces share the same seller fan-out implementation. They are different buyer projections, not different integrations. Start with get_products for new agent integrations. Use Discover Products when you need its legacy grouped/session presentation or need to continue an existing discoveryId workflow. Calling both for the same request is normally unnecessary; Discover Products projects the same raw seller snapshot rather than running another ranking implementation.

Surfaces

  • REST: POST /api/v2/buyer/products/query
  • Buyer MCP: get_products
  • Buyer api_call: operation get_products
The purchasable continuation is:
  • REST: POST /api/v2/buyer/media-buys/batch
  • Buyer MCP: create_media_buys
  • Buyer api_call: operation create_media_buys

Continue the selected product query

When the buyer selects returned proposals or products, reuse the exact ext.interchange.execution_id and storefront-qualified IDs from that get_products result. Do not call get_products or Discover Products again merely to attach, re-fetch, or reconstruct the selection. A new discovery is a different snapshot: its candidates may differ or be empty, and it does not identify the query the buyer selected from. The documented legacy recovery for an execution that predates its durable accepted-proposal set still requires a fresh get_products call; treat that response as a new selection and ask the buyer to select from it again. If an agent host offers the typed create_media_buys tool, call it directly. On a reduced or shared tool catalog where that typed tool is absent but buyer_api_call is available, use buyer_api_call with operation: "create_media_buys". Both tool routes invoke the same continuation contract. REST clients call the batch endpoint above. If the conversation no longer contains the execution ID or a qualified selected ID, report the missing handle and offer a new discovery as recovery; do not describe that new query as the original attachment.

Retrieval without evaluation

Omit ext.interchange.evaluation to receive valid seller responses without a managed evaluation:
Protocol validation always runs. Malformed seller responses, broken proposal allocations, and invalid identities are rejected before evaluation and do not consume evaluation work.

Include products from verified AdCP 2.x sellers

Some sellers still run a verified AdCP 2.x response contract, which predates the current required reporting_capabilities block. Their otherwise valid products are excluded from the default response because Apostra does not guess reporting cadence, delay, metrics, or webhook support. REST and MCP callers can request the separate compatibility response by adding the following field to get_products:
The response repeats that contract at ext.interchange.response_contract. A projected 2.x product omits reporting_capabilities and explains the omission explicitly:
Apostra accepts this projection only when protocol negotiation provides a non-synthetic, seller-declared 2.x version. Unknown, conflicting, synthetic, or current-version responses still fail closed. The exact seller product payload—not the buyer-facing projection—is retained for any later purchase. Omit response_contract to keep the original response schema, where every product requires reporting_capabilities. Existing REST and MCP integrations therefore need no changes. Sellers can remove the compatibility marker from future discovery by upgrading their declared contract and returning the current reporting block; no buyer-side account or purchase setting changes.

Request a smaller product projection

Use the canonical fields array when the next step needs only part of each product. Apostra validates the complete seller response before applying the projection, then returns only the requested product fields. The qualified product_id and ext.interchange provenance remain present so the result can still be refined or passed to create_media_buys.
For this request, unrequested product blocks such as format_options and reporting_capabilities are omitted. Omit fields when you need the complete canonical product objects.

Evaluate and negotiate proposals

Add evaluation instructions when you want Apostra to remove unsuitable proposals, enrich accepted results, negotiate promising proposals, and optionally rank the accepted cohort before it reaches your agent:
The first evaluation pass is absolute:
  • Accept returns the seller’s unchanged proposal and referenced products, with optional buyer-owned enrichment.
  • Reject suppresses the proposal from the normal result.
  • Refine sends a canonical AdCP refine instruction back to that proposal’s seller, then evaluates the seller’s revised response again.
Apostra never rewrites seller pricing, allocations, availability, or terms. Any changed proposal must come back from the seller. Autonomous refinement is bounded by max_refinement_rounds, repeated-request detection, the request deadline, and max_evaluation_passes. A proposal still requesting refinement at the limit is rejected rather than silently accepted. When ranking is present, a separate comparative pass scores accepted candidates under its optional instructions. The response is ordered by score and includes a rank and rationale. Provisional rankings replace earlier snapshots as sellers settle; the final completed revision is frozen. The platform selects the managed models. This version does not expose provider model names, customer executable code, or a model picker.
ext.interchange.screening remains a deprecated compatibility alias for accept, reject, and refine. It does not enable enrichment or comparative ranking. Do not send screening and evaluation together.

Discover Products compatibility

discover_products is a convenience view over the same seller fan-out and evaluation engine. Passing its optional evaluation field enables the same disposition, enrichment, refinement, and ranking behavior, projected into the Discover response shape. Without evaluation, Discover preserves its existing enrichment, relevance, ordering, and refinement behavior. Existing Discover clients do not need to migrate to keep their current behavior.

Proposals and products

Canonical AdCP proposals reference sibling products through allocation product_id values; they do not duplicate complete product objects. Evaluation resolves that graph automatically. A top-level product that is not allocated by any proposal remains a proposal-less candidate, principally for wholesale catalogs. pagination.max_results bounds the combined number of proposals and proposal-less products. Products referenced by a paged proposal accompany that proposal and do not consume another candidate slot, so the proposal graph is never split across pages. Apostra accepts at most 50 allocated products in one proposal. A response contains at most 100 total proposal and product objects; when complete proposal graphs reach that limit, the page returns fewer proposal candidates and the cursor continues from the next whole proposal. Both IDs are opaque and storefront-qualified:
Pass qualified IDs back unchanged in refine and media-buy creation calls. When more than one seller or storefront is in scope, Apostra rejects an item-scoped refine instruction that uses an unqualified product or proposal ID. Request-scoped refine instructions still apply across the request.
Two storefronts can expose the same upstream product and therefore carry the same ext.interchange.source_product_id, including the same wh: value. Treat the top-level qualified product_id as the identity, and use the storefront_id and storefront_name from that same product when displaying provenance. Never merge or relabel results by source_product_id alone.

Stage or buy the result

The execution_id in the response is also the durable search/refinement record used by create_media_buys. Choose whole proposals and/or proposal-less products from that execution. The operation stages them on a DRAFT campaign—the campaign is the shopping cart and the parent of every media buy. Only proposals in the execution’s durable accepted set can be selected. Run get_products again before purchasing from an older execution that predates that set. Use an existing campaign cart:
Or provide campaign.create with the same required fields as a discovery-mode campaign (advertiserId, name, flightDates, and budget). Apostra creates the DRAFT campaign and returns its ID. Media buys are never standalone, even when the caller did not create a campaign first.
  • mode: "stage" updates the cart and prepares DRAFT media buys without contacting sellers.
  • mode: "execute" performs the same preparation, then sends each resulting buy to its originating storefront through ordinary bilateral AdCP create_media_buy calls.
  • replace: true replaces the cart selection before applying this batch; otherwise selections merge idempotently.
Use mode: "execute" when the selected result can be submitted immediately. When you need to inspect or customize per-buy creatives, dates, pacing, or optimization goals, use mode: "stage", make those changes on the returned DRAFT media buys, then call execute_campaign or repeat create_media_buys with the same campaign and selections using mode: "execute". Execution submits the prepared DRAFT directly; it does not rebuild it from the product query. One marketplace call may reach several sellers, but the seller calls are not one atomic transaction. The response reports mediaBuysExecuted, success, and per-buy errors. Retry the same request after a partial failure; completed buys are not dispatched again. A completed product query accepts only an unchanged retry of its recorded batch. To change the selection after execution, start a new get_products query. See Create media buys from a product query for the complete request and response contract.

Progressive responses

Inside Murph, the runtime retains the original execution and request arguments, reads cumulative results with since_revision: 0, and follows producer-issued frozen cursors before attaching a selection. Its bounded wait can end with an incomplete result; that does not mean the query found no products. See Murph continuation and attachment for the wait limit, confirmed retries, and recovery behavior. Seller responses arrive independently. Each response is a replacement snapshot:
  • revision increases as sellers settle or a refinement produces a new version;
  • provisional: true means replace your previous displayed result;
  • pending_agents identifies sellers still working;
  • fresh provisional revisions may contain seller progress but no candidates while typed product identity is still being persisted;
  • proposals being refined are withheld;
  • ordinary incomplete responses set pagination.has_more: false and issue no cursor; poll the execution_id with since_revision instead. No stable cursor is issued until results_complete: true;
  • storefront_results[].message preserves a seller’s explanation for a successful empty response, including when readiness prevents that storefront from transacting; do not interpret that case as “no matching inventory”;
  • storefront_results[].error: "product_persistence_incomplete" means the storefront answered, but one or more candidates could not be persisted with a complete seller/source identity. Those candidates and any proposals that allocate to them are withheld. Safely persisted candidates from the same execution are still returned; start a new query to retry the withheld inventory. If no safe candidate remains, the request returns a retryable service-unavailable error instead of an authoritative empty catalog;
  • the final cursor is scoped to the execution, revision, evaluation instructions, and offsets.
Response speed affects which sellers are visible in an early provisional revision, not which sellers are retained in the completed execution. Without comparative ranking, completed products use deterministic storefront-qualified identity ordering; when evaluation.ranking is present, the accepted cohort is ordered by that ranking instead. Poll with the returned execution identity:
Do not append provisional pages. Replace the prior page with the new revision. Evaluation is idempotent by candidate version and instruction fingerprint. Polling an unchanged revision does not rerun or remeter it. A seller’s changed proposal is evaluated as a new candidate version.

Evaluation metadata and cost

The response reports evaluation separately from seller progress:
One Proposal Evaluation Pass evaluates a bounded batch of at most ten candidates. One Comparative Proposal Ranking Pass ranks at most 100 accepted candidates. We measure these value units rather than provider tokens. Both are currently calibrating, so charged_ius remains 0. Retrieval without evaluation reports billing_status: "included". If managed evaluation is unavailable, valid candidates pass through and the evaluation status becomes degraded; an infrastructure failure does not silently reject seller supply. Every pass-through candidate is explicitly marked ext.interchange.evaluation.evaluated: false (with confidence: 0), and the response guidance says so. Check evaluated before treating an accept as a judgment: evaluated: false means your evaluation instructions were not applied to that candidate — re-run the request or apply your own screening before buying on it.