Skip to main content
Build the first version as a read-only client, then add one durable buying step at a time. Your application runs the agent and stores workflow progress. Apostra keeps the advertising objects, contacts eligible sellers, executes supported transactions, and returns delivery. Complete the connection quickstart first. This guide describes the application architecture; the buyer workflow reference contains the exact current request shapes for each buying step.

Start with seller discovery

To practise without credentials, use the local planning exercise. It checks fictional data and produces a local receipt; it does not complete the connected steps below.
  1. Connect an MCP client to /mcp/v3 and authenticate.
  2. Call get_status; verify the intended Buyer Account before using its data.
  3. Read tools/list and expose only the tools your prototype needs to its model.
  4. Call search with {"kind":"seller","limit":10}. Follow returned cursors if you need the complete list.
  5. Display the sellers and their returned readiness and connection information. Keep missing or blocked states visible.
This discovery read does not request proposals. request_proposals is a later step that sends a campaign brief to the eligible seller cohort. It should run only when the user has asked your application to start that buying workflow. Use the structured result for IDs and readiness. A model can explain the result but should not reconstruct qualified identifiers or extract them from prose.

Connect a seller when the workflow needs it

Read the selected seller’s connection and activation state before requesting offers. Use search or get for existing connection records and the current save_connection schema for the required selection, billing, authorization, provider-account choice, or advertiser mapping. An authorization action returns a browser URL; provider credentials do not belong in the tool arguments. After the person completes any required browser step, re-read the seller and connection. A listed seller is not necessarily ready for this advertiser to buy from. The buyer workflow explains these readiness checks before campaign authoring and proposal requests. For one advertiser, call get with kind: "seller", the seller id, and advertiserId, or pass filter.advertiserId to search for sellers. The returned controlPlane.activation.state, requiredAction, and advertisers describe only that advertiser. An active advertiser is ready at the activation step even when another advertiser still needs setup. Its own missing setup, pending seller approval, or missing grant still appears in its activation. If no activation record exists for the requested advertiser, the state is inactive and advertisers is empty; this does not establish readiness. Omit advertiser scope to read the account-wide activation summary and all its advertiser rows. Account selection and billing remain account-wide on both reads. Activation readiness does not guarantee that a proposal request will return products or that every booking requirement has been met.

Give the loop a durable owner

For a production hybrid application, keep these records outside the model’s conversation: The loop is: Application code validates the model’s proposed tool and arguments against the current schema and your allowed actions. Stop a run when it needs a person, is waiting on asynchronous work, has reached its call budget, or has no valid next step. A scheduled worker or a supported notification can resume it later.

Add buying steps

Campaign reads and campaign search text label a flight in the advertiser’s current default timezone and in UTC. The returned flight.startAt and flight.endAt values remain the stored UTC instants. If the advertiser timezone cannot be read, the text labels the UTC window instead. The launch preview and confirmation use separate idempotency keys. If the campaign changes after approval, obtain a fresh preview and approval rather than confirming a different draft. Content approval, seller creative review, and launch approval are separate decisions. A launch can fail after you confirm it. If it failed because a creative was refused and only you can change that, Apostra does not retry it: fix the cause the error names and confirm the launch again straight away. If it failed for a reason that may clear by itself, such as a seller that could not be reached, Apostra sends it again on its own, up to five attempts, and confirming the launch meanwhile returns CONFLICT with details.reason LAUNCH_IN_FLIGHT. That refusal names the launch and its status, and details.retryAfter says when it is sent again. Confirm again after that time: the launch has either gone through or run out of attempts and been released. Campaign get returns the saved targetingOverlay in its structured object and text response without an include. For example, saving targetingOverlay: { geo_countries: ["US"], language: ["en"] } lets you read back both constraints at the saved revision. An unset or cleared overlay is omitted from the object and reported as not set in text. Campaign search returns summaries; use the campaign ID from a result to read targeting with get. The campaign overlay records campaign intent. It does not confirm that each media buy or seller has applied those settings. get with kind: "media_buy" and include: ["products"] returns each package’s effective targeting_overlay, including placement_selection and every other dimension accepted on save_media_buy.products[].targetingOverlay, in both the structured object and the text response. This reflects what was requested, filled in with the buyer’s own request wherever the seller’s own readback is silent on a dimension — it is not independent confirmation that the seller’s live ad-set state matches. A seller that confirms targeting server-side overrides the buyer’s requested value for that dimension. The fill-in happens when a package is written (on create, on update, or on a routine status sync): a package with no write since this behavior shipped may still omit a dimension the seller’s own readback does not report until its next write. When a buyer sets targetingOverlay.demographics.age, send kind: "minimum", "maximum", or "range" to match the supplied bound fields. Campaign bidding also requires kind: "cost_per" or "roas", and every channelGroups item requires kind: "preset" or "custom". These are closed request variants: send only the fields for the selected kind.

Discovery results belong to the buyer account

Every product a buyer finds through a storefront, whether the storefront hosts the seller or an external seller sits behind it, and whether it arrives through request_proposals, search, or get_products, is saved to that buyer’s account. Prices and product details are account-specific, so a buyer never sees another buyer’s products. A product is staged only from the buyer’s own saved results, so a selection that predates them must be found again first.

Resume a proposal request

A fresh request_proposals call durably schedules seller discovery. It normally returns running. Persist the original campaign revision and idempotency key before making the call, then repeat that same request until its status is complete, partial, or failed. Once terminal, follow every page.nextCursor using the documented continuation arguments. One seller can span several result pages. A partial result is not permission to discard failed seller outcomes, and the first page is not the whole result. Only one proposal execution can run per buyer across campaigns. A new key starts a new round; it is not a polling mechanism. See Request proposals. Sellers are asked as the campaign’s advertiser’s own account with each seller. A seller where the advertiser has no active account is not contacted. When you did not name sellers, those sellers are only counted: if none qualifies, the round fails with NO_ELIGIBLE_SELLERS and says how many are still setting up the advertiser’s account and how many need attention. When you named a seller, its perSeller result carries outcome.kind: "skipped" with reasonCode account_pending (setup is on its way), account_needs_attention (the buyer, an operator or the seller has to act; the message points to Media Partners). A round queued before requests recorded who sent them cannot present a company account, so each such seller is skipped with reasonCode round_predates_account_rule and the message says to run the request again. A seller closed to buyers keeps eligibility instead of an account reason, including a closed seller named by a buyer that seller sponsors. If the buyer has not finished setup, the round fails with BUYER_SETUP_REQUIRED and names the step; that failure is final, so complete the step and request again. The rule is the same for product discovery through search and get; see Get products across storefronts. When you read one seller’s products with get({ kind: "seller", id, advertiserId, include: ["products"] }) and the advertiser has no active account with that seller, the seller’s catalog.ext.interchange.storefront_results[] row is skipped with reason_code account_pending (setup is on its way, nothing to do) or account_needs_attention (the buyer, an operator or the seller has to act), and a suggestion that points to Media Partners. The text says the same on a Not searched line. A seller that answers a wholesale browse by saying your advertiser’s account needs setup is reported the same way by search({ kind: "wholesale_product" }), as finished with account_needs_attention rather than partial. get of one of its products returns BUYER_SETUP_REQUIRED with that reason in details.reason, not NOT_FOUND.

Synthetic sellers say so on every proposal product

Some sellers serve synthetic sample inventory instead of a real publisher’s: a Demo Storefront, and the Apostra synthetic seller that exists for agent demos and checks. Such a seller’s data is not suitable for commercial decisions. When you read a proposal with get({ kind: "proposal", id, include: ["products"] }), each product row from a synthetic seller carries synthetic: { label, disclosure }, and the text of the read repeats the disclosure. The field is absent for every other seller. Show the disclosure to whoever reads the result. If Apostra cannot tell whether the seller is synthetic when you read the products, the read fails with SERVICE_UNAVAILABLE and returns no products; read it again in a moment. When the request comes from a sandbox account, a synthetic seller also sets AdCP’s own sandbox: true on its get_products and request_proposals responses, which AdCP defines as “this response contains simulated data from sandbox mode”. A request from a live account gets no such flag from the same seller, and no other seller sets it on Apostra’s behalf: the flag says the response is simulated, and only a synthetic seller’s responses are.

Prototype against the synthetic seller in a sandbox, with no billing

You can request proposals from the Apostra synthetic seller without billing details, payment terms or a payment method, as long as the campaign’s advertiser is a sandbox advertiser. A sandbox request spends nothing, so Apostra does not ask the buyer to set up payment first. This is a sandbox rule, not a general one. A live advertiser still needs the buyer’s billing details before a seller that bills through Apostra is asked for a proposal, so that no seller quotes a buyer it cannot bill. When the sellers on a live campaign are not eligible for that reason, request_proposals returns CAMPAIGN_SELLERS_INELIGIBLE and names them. The sandbox exception reaches only sellers that declare sandbox support, which is AdCP’s account.sandbox capability in get_adcp_capabilities. The Apostra synthetic seller and every Demo Storefront declare it, because everything they sell is synthetic. A seller that does not declare it is never asked for a proposal on behalf of a sandbox advertiser: it stays out of the round and, if you named it, request_proposals returns CAMPAIGN_SELLERS_INELIGIBLE. Real publishers’ sellers ask for billing details on live advertisers, and most do not declare sandbox support, so a sandbox prototype that works against the synthetic seller does not show that a real seller will take the request. To try it, create an advertiser with sandbox: true, switch the synthetic seller on for it, save a campaign, and call request_proposals. Setup other than payment still applies: the Terms of Service accepted, a plan selected and a verified organization identity.

See what each seller was told

When you request proposals, Apostra sends each seller a brief made from your campaign, keeping back what that seller does not need, such as your total budget. To see exactly what one seller was sent, read its proposal with get({ kind: "proposal", id, include: ["brief"] }). brief.request is the brief as it was sent, frozen at that moment: the campaign name and outcome, flight, geo, demographics, channels, language, devices, formats, audience references and goals, with null for anything not sent. brief.withheldCategories names what was kept back. A later change to the campaign does not change a brief already sent, so compare it with the current campaign to see what has moved. If the brief was too large to keep, brief.complete is false and brief.request is null.

Recover from interrupted writes

A timeout or disconnected MCP session does not prove a write failed. Preserve the request, inspect any structured error and partialWrite evidence, and re-read the affected object before retrying. Reuse an idempotency key only for the same logical operation and unchanged arguments. Follow a tool’s specific contract where it supplies its own identity, such as the productQueryId used when staging returned products. Connections and individual tool calls have time limits and can be interrupted by deployments. A workflow that spans creative review or seller execution must survive reconnection. See Client setup and Capability limits.

Give independent software its own identity

An interactive assistant using a person’s OAuth connection requires no buyer-agent registration. For independently operated software, an organization administrator can register an external buyer agent, issue its credential, and grant exact advertiser access through buyer-agent management. These controls require the documented direct administrator session. Where the current catalog advertises save_buyer_agent, use it with the matching get/search reads. Use the documented console or REST path for controls the active v3 catalog does not expose. Do not assume a runtime credential can register itself or grant its own advertiser access. Registration gives software an identity; it does not make Apostra the hosted runtime for that software.

Test the boundaries

Use deterministic fixtures to prove that your application handles an empty seller list, a blocked account, a multi-page proposal result, an interrupted write, and a changed campaign revision. Test that a proposed launch cannot pass the application’s approval checkpoint without an approval for the exact draft. Then use the sandbox workflow for live transaction verification. A seller can follow the no-spend own-supply recipe and the published Test a Sales Agent skill. Sandbox access is scoped; it does not prove eligibility for live supply, funding, or production launch.