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.- Connect an MCP client to
/mcp/v3and authenticate. - Call
get_status; verify the intended Buyer Account before using its data. - Read
tools/listand expose only the tools your prototype needs to its model. - Call
searchwith{"kind":"seller","limit":10}. Follow returned cursors if you need the complete list. - Display the sellers and their returned readiness and connection information. Keep missing or blocked states visible.
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. Usesearch 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 throughrequest_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 freshrequest_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 withget({ 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 withget({ 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 andpartialWrite 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 advertisessave_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.