refine_proposals has two explicit operations over immutable proposal snapshots:
action: "revise"applies structured discoverycriteria, typed boundaries, product changes, requested alternatives, and/or the buyer’s free-textask, returning one or more new draft proposals.action: "finalize"changes no commercial terms and returns a new committed proposal backed by an inventory hold untilexpires_at.
proposal_id; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged.
Request schema: /schemas/3.0.23/media-buy/refine-proposals-request.json
vary and typed preference enums are intentionally absent: concepts such as “prioritize reach” cannot be verified consistently against commercial_terms. A seller may interpret them from ask, but cannot report them as deterministically satisfied.
Each present criteria field replaces that field from the source proposal; omitted fields remain bound to the revision. This makes a targeting change deterministic and ensures the revised proposal’s price and forecast use the new effective overlay. When hard targeting appears only in ask, the seller confirms any structured interpretation that materially affects the revision in that result’s targeting_resolution.brief_targeting. Exact structured targeting is not repeated; product-specific alternatives remain sparse modifications on the returned configured products.
Response schema: /schemas/3.0.23/media-buy/refine-proposals-response.json
The response contains one ordered results[] entry per requested source proposal. Each entry echoes source_proposal_id; buyers cross-check by that echo rather than by position alone. Every returned proposal carries parent_proposal_id equal to the entry’s source_proposal_id, so negotiation lineage survives in the proposals themselves:
reason_code distinguishes commercial refusal from implementation limits:
Typed failures always use
constraint_unsatisfiable, regardless of whether the refusal is capability or commercial; won’t-versus-can’t nuance belongs in reason or a commercially_declined response to an ask. When multiple shortfalls co-occur, constraint_unsatisfiable takes precedence over every other code, and an alternatives shortfall remains visible through proposals.length.
A draft that violates a hard constraint MUST NOT be returned as revised. For constraints.total_budget, satisfaction requires a present commercial_terms.total_budget, the requested currency, and an amount inside every supplied bound. An absent total, currency mismatch, or out-of-range amount is unsatisfied: the seller returns partial, or unable when it cannot produce an honest draft, with reason_code: "constraint_unsatisfiable" and unsatisfied_constraints: ["total_budget"]. The other constraints keys behave identically under their satisfaction rules in the request schema.
Every draft on a partial result MUST satisfy every typed constraint whose key is absent from unsatisfied_constraints and every product action absent from unsatisfied_product_changes, whatever the reason_code. This is the invariant that lets a buyer trust typed compliance without parsing prose. Constraint failures are identified through unsatisfied_constraints (echoing request constraints keys), unsatisfied_product_changes (a subset of the requested product-action map), or both.
A partial response to the request above looks like:
commercial_terms and a terms_digest — the base64url SHA-256 of the RFC 8785 JCS serialization of commercial_terms, prefixed with sha256:. Buyers can and should recompute it: digest agreement, not seller assertion, is what makes drafts comparable and alternatives verifiably distinct. Only commercial_terms is contractual. Narrative fields — reason, suggestions, description, and anything a buyer wrote in ask — are explanatory and never part of the deal a later accept_proposal binds.
A realistic negotiation
Sam is buying a $50,000 Acme Outdoor campaign from StreamHaus. Capability discovery says StreamHaus supportstotal_budget, product_changes, alternatives, and criteria, with max_alternatives: 3. Sam can therefore construct one mechanically checkable request: cap spend at USD 50,000, include premium video, omit run-of-site display, narrow geography to the US and Canada, and ask for three distinct mixes.
Suppose StreamHaus can satisfy the budget and targeting but can produce only two commercially distinct drafts. It returns partial with two proposals, reason_code: "alternatives_unavailable", and unique terms_digest values. Sam does not need to parse sales prose to understand the counteroffer: the typed budget and product checks still pass, while the requested alternative count does not. Sam can compare the two drafts, revise one again, or finalize the preferred draft.
The failure paths are equally deterministic:
- If a returned draft omits
commercial_terms.total_budget, uses another currency, or exceeds the cap, the result identifiestotal_budgetas unsatisfied and cannot berevised. - If Sam requests four alternatives against the advertised ceiling of three, the whole call fails with
VALIDATION_ERRORatrefinements[0].alternatives.count; StreamHaus creates no draft. - If StreamHaus omits
criteriafromsupported_dimensions, the whole call fails withUNSUPPORTED_FEATUREbefore mutation. Sam can remove or translate that field and retry with a new valid payload.
ask remains available for the commercial nuance that cannot be reduced to a reliable boolean.
Pass only a finalized committed proposal to accept_proposal, or use decline_proposals when the buyer stops pursuing it.
Relationship to legacy refinement
The deprecatedget_products buying_mode: "refine" surface is the 3.x compatibility façade; refine_proposals is the canonical compact-lifecycle negotiation task. The two surfaces share ask, product include/omit verbs, and the same budget_range {min,max,currency} shape — with inverted semantics. Legacy budget_range is a soft discovery filter the seller may miss without failing; compact constraints.total_budget is a hard constraint that forces partial or unable when violated. An adapter that copies the shape without converting the semantics silently turns a preference into a rejection. Compact hard constraints also reject unknown range members and require the semantic min <= max check; legacy filters remain extension-tolerant. The surfaces do not share response envelopes: legacy calls may return position-matched refinement_applied[], while compact calls return one results[] entry per source proposal and prohibit refinement_applied.
Adapters may map a single legacy proposal refinement into the compact task, but must not invent support for per-proposal constraints or alternatives that the legacy envelope cannot represent faithfully. The legacy more_like_this verb has no typed compact equivalent and maps only lossily into ask.
Finalize and hold inventory
Finalization is seller commitment, not buyer acceptance. It locks the already-negotiated terms and reserves inventory until the returnedexpires_at:
finalize must contain only finalize entries. Sellers validate the complete batch before creating any holds; multi-proposal finalization is atomic. When any hold cannot be created, no result is finalized: the failing entry returns unable with reason_code: "hold_unavailable", and otherwise-eligible siblings return unable with reason_code: "batch_aborted". Exact retries use the same idempotency key. A new key does not extend or recreate an existing hold: while a committed successor’s hold is unexpired, finalizing the same draft again MUST fail at task level with INVALID_STATE rather than minting a second hold; after expiry a new finalize MAY create a new one.
Holds are a seller commitment against real inventory, so hold policy is the seller’s. Sellers MAY decline any hold (hold_unavailable), MAY cap concurrent unexpired holds per buyer to prevent inventory squatting, and choose expires_at themselves — the hold window should be long enough to complete acceptance and short enough that unaccepted holds do not function as free inventory options. Nothing in this task obliges a seller to grant holds without whatever credit or relationship checks it applies to reservations generally; the async submitted path exists for exactly that re-underwriting.
Every revised proposal inherits the source proposal’s opportunity association. Callers do not repeat opportunity context during refinement.
Negotiation security
Both sides of this task are agents, and both sides’ inputs are untrusted.ask is attacker-controlled free text: sellers MUST NOT let it alter pricing floors, authorization, or policy, and MUST NOT expose other buyers’ terms through it — fence it from any LLM with pricing authority and treat its interpretation as suggestion, never instruction. In the other direction, deterministic constraint_unsatisfiable responses form a price oracle: repeated budget or rate probes can binary-search a floor at no cost. Sellers SHOULD apply rate or commercial friction to sustained un-held revision rounds and MAY keep reason deliberately coarse rather than revealing how near a rejected constraint came to clearing.
Amend an accepted MediaBuy
An accepted proposal is the immutable commercial envelope attached to its MediaBuy. Refining it forks a successor proposal rather than reopening or mutating the accepted snapshot:change_kind: "cancellation" when cancellation requires counterparty agreement. The returned draft has proposal_kind: "media_buy_cancellation", identifies the MediaBuy and base revision, and must be finalized before it can be applied through accept_proposal.
Sellers MAY respond asynchronously with status: "submitted" when a complex multi-publisher revision or inventory hold requires upstream pricing queries or manual re-underwriting. In that case the response contains a task_id for polling via get_task_status; terminal results are delivered on the completion artifact or via push notification if push_notification_config was supplied.