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

  1. The buyer authenticates. The API key maps to an access tier on the seller side, and pricing starts from there.
  2. The buyer browses the media kit and POSTs to /api/v1/quotes. The seller prices it and mints the quote_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 an expired value and no code path in the seller ever sets it.
  3. Either agent can open a negotiation, quote-led against that quote_id or proposal-led against a proposal_id, which is a seller-minted buy proposal already under discussion. The move is a NegotiationMessage: a required action, a price in micros (millionths of a currency unit, integers only, carrying an ISO 4217 code that defaults to USD), an idempotency_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.
  4. The answer is accept, counter, final_offer or reject, 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.
  5. The exchange ends on an accept, a reject, a final_offer taken or refused, or the round limit. The buyer books the quote and the seller returns a deal_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.

Concession budget by access tier, from seller-agent docs/integration/negotiation.md. One seller's internal policy: none of these values crosses the wire, and no other implementation is bound by them.
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 unknownregisteredapprovedpreferred , 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.

Related