Price negotiation in AAMP
AAMP price negotiation is a typed multi-round exchange where the buyer sends an action and a price and the seller answers accept, counter, final_offer or reject, bounded by concession caps the shared schema deliberately keeps off the wire.
AAMP negotiation is a typed multi-round price exchange. The buyer sends an action and a price in micros; the seller answers with one of four actions and a price, and every round is recorded with the concession it cost. AAMP expands to Agentic Advertising Management Protocols, IAB Tech Lab's umbrella over a set of agent repositories, and the seller agent holds the pricing engine.
spec/jsonschema/Negotiation.json in iab-agentic-primitives describes itself
as the seller's negotiation history "minus everything that must NOT cross the wire". The floor price,
the base price, the strategy and the concession limits are "seller-internal guardrails and are deliberately
absent from the shared schema". The message sequence is shared and the bounds are not, so a seller's
concession policy is published documentation rather than specification. The library that carries the
schema opens its README "WORK IN PROGRESS — NOT YET RELEASED".
The negotiation sequence
- The buyer authenticates. The API key maps to an access tier on the seller side, and pricing starts from there.
-
The buyer browses the media kit and POSTs to
/api/v1/quotes. The seller prices it and mints thequote_id. The quote is non-binding and expires 24 hours later. No document states what happens to an open negotiation when the quote expires. The status enum has anexpiredvalue and no code path in the seller ever sets it. -
Either agent can open a negotiation, quote-led against that
quote_idor proposal-led against aproposal_id, which is a seller-minted buy proposal already under discussion. The move is aNegotiationMessage: a required action, a price in micros (millionths of a currency unit, integers only, carrying an ISO 4217 code that defaults to USD), anidempotency_key, and optionally the round number the sender believes it is answering. A round number that disagrees with the seller's comes back as a contention error; the seller's numbering is authoritative. -
The answer is
accept,counter,final_offerorreject, and a round is appended to a history both sides read from the same schema. That round carries the concession it cost and a free-text rationale. -
The exchange ends on an accept, a reject, a
final_offertaken or refused, or the round limit. The buyer books the quote and the seller returns adeal_id. Every identifier in the flow is seller-issued. The buyer agent's documentation gives the reason: it stops buyers fabricating deal IDs client-side.
None of quote_id, proposal_id or deal_id carries to a second
seller: each is minted by the seller that issued it.
idempotency_key is required on every negotiation message, on the shared model's own stated
grounds that a retried counter must not consume an extra round
. The seller's handler for POST /api/v1/negotiations/messages never reads the field. A retried message runs the pricing engine again and appends another round,
and rounds are the budget the concession caps are spent from, so a buyer agent cannot safely retry
a request to that endpoint. The same repository does honour idempotency on deal booking, where routers/deals.py keys a replay off idempotency:deal:{key}.
The per-round cap binds before the gap share does
The seller agent applies three limits, all keyed to the buyer's access tier: a per-round cap on
how much it gives away at once, a total cap it will not cross however long the exchange runs,
and a gap share, the fraction of the current price gap it concedes in a round. Gap share is
computed in negotiation_engine.py. With the seller opening at a 120 CPM and the
buyer countering at 100, the gap is 20 and the advertiser tier's share is 65%, so the gap share
alone would concede 13 and answer 107; the per-round cap is 6% of the opening price, 7.20, so
the engine answers 112.80. On later rounds the gap is measured from the seller's last counter
rather than from where it opened, so each concession is a share of a shrinking number.
| Access tier | Strategy | Max rounds | Per-round cap | Total cap | Seller concession share of gap |
|---|---|---|---|---|---|
public | aggressive | 3 | 3% | 8% | 30% |
seat | standard | 4 | 4% | 12% | 40% |
agency | collaborative | 5 | 5% | 15% | 50% |
advertiser | premium | 6 | 6% | 20% | 65% |
The seller agent's docs label that column "buyer gap share" and gloss it as how much of the gap
the seller expects the buyer to close, "higher = more favorable to seller".
negotiation_engine.py computes the seller's concession as the gap times that share, so
a bigger number moves the seller further, which is why the best-treated tier carries the biggest one.
The one negotiation bound a buyer can observe is rounds_remaining, defined as
"Rounds left before the seller walks away, if disclosed". It is optional, nullable and defaults
to null, so a buyer agent negotiating against a conformant AAMP seller cannot compute when to
stop. The reference buyer picks a limit for itself: SimpleThresholdStrategy takes a
max_rounds from the buyer's own settings, defaulting to three.
The eligibility rule has zero call sites
The two reference agents give different answers on which buyers may negotiate. The seller
publishes a strategy for all four tiers, public included, at three rounds. Its own buyer
model restricts it: eligible_for_negotiation returns true only for
agency and advertiser. The buyer agent's deal-booking guide follows
the model rather than the table: "Only Agency and Advertiser tier buyers. PUBLIC and SEAT tier
buyers skip this step."
eligible_for_negotiation has five assertions in the test suite and zero call sites in
src/, so neither rule is enforced. Two routes reach the engine, the legacy
POST /proposals/{proposal_id}/counter and the canonical
POST /api/v1/negotiations/messages, and both do the same two things: cap the
claimed tier at what the registry will verify, then run the engine. What the registry can verify
differs by surface: a quote request carries the agent's URL and a negotiation message does not.
Neither route checks whether the buyer was entitled to negotiate. Two of the four rows in the
concession table describe a strategy the seller applies to buyers its own documentation excludes
from negotiation.
Access tier is capped by verified trust status
The effective tier is the smaller of two values: the tier a buyer claims, and the ceiling its
trust status allows. An unverifiable claim floors to public, so self-asserted
identity never raises a buyer above what the seller can check. The trust ladder runs
unknown → registered → approved → preferred , where registered means the seller found the agent in the AAMP registry.
TRUST_TO_TIER_MAP collapses those four values into three ceilings:
approved and preferred both map to advertiser, so a
manually approved partner and a strategic one land in the same place. A fifth status,
blocked, maps to no tier, and the request is rejected before any pricing data
moves.
The shared NegotiationMessage has no agent_url field, so there is nothing
in the message to look up in the registry. The hole is in the shared primitives package rather than
in the seller: QuoteRequest carries the field, NegotiationMessage does not,
and the seller's own code comment says so. A message posted to
/api/v1/negotiations/messages can therefore be checked against an API key only, and an
anonymous caller floors to public whatever its registry status.
The legacy counter route applied no trust-tier ceiling at all until the change a source comment
marks EP-5.2: a buyer that set advertiser_id on the request received advertiser pricing,
so the tier, and with it the price, was set by a field the caller supplied. On the current checkout
that route calls the trust-verification path. The same self-asserted identity fields still drive the
tier decision on POST /proposals and on the message endpoint.
EP-5.2 is an internal change marker rather than a tag or a release: the comment does not name the release that carried it, and no version in the corpus resolves it, so which checkouts hold the fix is decided by reading the route rather than by a version number.
AdCP has no counter-offer message
AdCP 3.1.13 carries the quote-then-book shape and no bargaining round: a proposal sits at
draft, indicative pricing a buyer can plan against, until a finalize
action turns it committed, firm pricing with inventory reserved until
expires_at, and executing it with create_media_buy is acceptance. A buyer
that wants a lower number sends a refine entry whose ask field is a free-text
string, example "reduce total by 10%"; the seller answers applied,
partial or unable, with prose in notes. Neither side of
that exchange carries a price field, nothing counts the rounds, and no AAMP repository mentions
AdCP anywhere. The two stacks are compared operation by operation on the hub.
What the negotiation surface supports today
The surface applies where price is set per deal — OOH, CTV sponsorships, direct deals — which is the same set AdCP's docs name; a transaction at a published rate card does not use it. The seller agent's README opens "V2 — Feature Complete", and no file in either corpus records a live AAMP negotiation between two parties.
Frequently asked
- Does AdCP have a negotiation protocol?
- No bargaining one. Across all 64 operations in AdCP 3.1.13 there is no counter-offer message, no round record and no concession field. AdCP does have the quote-then-book shape (a proposal moves from draft to committed through a finalize action) plus a refinement channel where the buyer's ask is free text.
- What are the four negotiation actions?
- accept, counter, final_offer and reject. The same enum is required on the buyer's NegotiationMessage and on the seller's round, so either side can close or walk away. Each round records the round number, both prices, the action, the concession, the cumulative concession, a rationale and a timestamp.
- How long is a quote valid?
- Twenty-four hours, hard-coded rather than negotiated or seller-configurable. After that, retrieving the quote returns HTTP 410 and the buyer has to ask for a new one. Quotes are non-binding until booked into a deal, at which point the seller issues the deal_id.
- Are the concession caps part of the specification?
- No. The floor price, the base price, the strategy and the concession limits are seller-internal in the shared schema's own description, and none of them crosses the wire. The published table is one seller's policy, and a buyer agent cannot read it.