Blog

An OpenRTB Bid Request Example, Annotated Field by Field

A complete OpenRTB 2.6 bid request and response, annotated object by object: what each field means, what the spec requires, and what real exchanges do.

24 min read By the Floxis engineering team

An annotated OpenRTB bid request fragment marking the fields most often got wrong

Every RTB integration arrives at the same moment: a bid request captured from a log, open in an editor, and a field nobody at the table can confidently explain. The OpenRTB spec answers most of these questions, but across a hundred-plus pages organized by object — not by the question you actually have, which is usually “is this required, and what happens if it’s missing?”

This page is that reference. Below is a complete, well-formed OpenRTB 2.6 bid request, then every object in it, annotated: what each field means, whether the spec requires it, what a working exchange does when it is absent, and the mistakes that keep showing up. Then the bid response gets the same treatment, including the win-notice machinery that causes most of the arguments.

Two notes before the JSON. Every claim here is checked against the IAB Tech Lab OpenRTB 2.6 specification and the enumerated lists it delegates to AdCOM 1.0; where spec and practice diverge, the divergence is called out, because that gap is usually what costs you the debugging afternoon. And the JSON is fully synthetic — reserved documentation domains and IP ranges, invented IDs, truncated privacy strings that do not decode.

Table of Contents

The full bid request

A mobile-web news page, one ad slot offered as either a banner or an outstream video, a US user, a one-hop supply chain:

{
  "id": "b5ba5ed2-547e-4e86-8a84-34a440dad6db",
  "test": 0,
  "at": 1,
  "tmax": 300,
  "cur": ["USD"],
  "bcat": ["IAB25", "IAB26"],
  "badv": ["competitor-brand.example.org"],
  "imp": [
    {
      "id": "1",
      "tagid": "article-mpu-1",
      "instl": 0,
      "secure": 1,
      "bidfloor": 0.85,
      "bidfloorcur": "USD",
      "banner": {
        "format": [
          { "w": 300, "h": 250 },
          { "w": 336, "h": 280 }
        ],
        "pos": 1,
        "topframe": 0,
        "battr": [1, 3]
      },
      "video": {
        "mimes": ["video/mp4", "video/webm"],
        "minduration": 5,
        "maxduration": 30,
        "protocols": [3, 6, 7, 8],
        "w": 640,
        "h": 360,
        "plcmt": 2,
        "linearity": 1,
        "startdelay": 0,
        "skip": 1,
        "skipafter": 5,
        "playbackmethod": [2]
      },
      "ext": {
        "gpid": "/news-site/article/mpu-1"
      }
    }
  ],
  "site": {
    "id": "site-1042",
    "name": "Example News",
    "domain": "news.example.com",
    "page": "https://news.example.com/2026/08/local-election-results",
    "cat": ["IAB12"],
    "mobile": 1,
    "publisher": {
      "id": "pub-4821",
      "name": "Example News Media",
      "domain": "example.com"
    }
  },
  "device": {
    "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
    "ip": "203.0.113.44",
    "devicetype": 1,
    "make": "Apple",
    "model": "iPhone",
    "os": "iOS",
    "osv": "17.5",
    "js": 1,
    "language": "en",
    "connectiontype": 2,
    "ifa": "3f5e6a1c-9d0b-4b2e-8c7a-1a2b3c4d5e6f",
    "lmt": 0,
    "geo": {
      "lat": 40.7306,
      "lon": -73.9866,
      "type": 2,
      "country": "USA",
      "region": "NY",
      "city": "New York",
      "zip": "10003",
      "utcoffset": -240
    }
  },
  "user": {
    "id": "exch-u-8c41f2d7",
    "buyeruid": "dsp-u-193728",
    "eids": [
      {
        "source": "idprovider.example.org",
        "uids": [
          { "id": "u-1c9e3f77-2b0a-4f4e-b1a8-77d0c2f1e9aa", "atype": 1 }
        ]
      }
    ]
  },
  "source": {
    "fd": 0,
    "tid": "6f0ed295-6dd8-42a3-9fbc-3f31e2b4e8f1",
    "schain": {
      "ver": "1.0",
      "complete": 1,
      "nodes": [
        {
          "asi": "exchange.example.net",
          "sid": "pub-4821",
          "rid": "b5ba5ed2-547e-4e86-8a84-34a440dad6db",
          "hp": 1
        }
      ]
    }
  },
  "regs": {
    "coppa": 0,
    "gdpr": 0,
    "us_privacy": "1YNN",
    "gpp": "DBABLA~BVVqAAEABgAA.QA",
    "gpp_sid": [7]
  }
}

How to read the annotations

The spec defines three levels of obligation, and integration arguments usually turn on its exact wording. Required means omission “would technically break the protocol.” Recommended means optional, but flagged for “elevated business importance.” Everything else is optional, and unless a default is explicitly specified, an omitted attribute means unknown — not zero, not false. The few fields that do carry spec defaults (at, test, bidfloor, bidfloorcur) are exactly where a silent default can surprise you.

The envelope: bid requests are HTTP POST — never GET — with Content-Type: application/json and the protocol version in a custom header, x-openrtb-version: 2.6, so a bidder can pick a parser before touching the body. Three response codes cover everything: 200 for a response with content, 204 for a valid call answered with none (the classic no-bid), and 400 with no body for an invalid call — the spec’s own example is “a bid request containing a malformed or corrupt payload.” The operational consequences of confusing 400 with 204 — retry storms, QPS cuts, buried integration errors — are covered in our guide to setting up an RTB exchange; this article sticks to which payloads deserve which code.

BidRequest: the top level

The top-level object carries the auction’s identity and the rules that apply to every impression inside it.

Field Spec status Meaning, and behavior when missing
id required The auction ID — the key every later bid, win notice and discrepancy report references. Absent: malformed, 400.
imp required The impressions offered; “at least 1 Imp object is required.” An empty array is a schema violation answered with 400, not a polite 204 — see below.
site / app / dooh recommended / recommended / optional The context object; at most one may be present. See below.
test default 0 1 = auction not billable. A partner who sends test: 1 and gets an invoice line remembers it.
at default 2 Auction type: 1 = first price, 2 = second price plus, 500+ exchange-specific. The most misleading default in the protocol — see below.
tmax optional Milliseconds allowed for the response, “including Internet latency”; it “supersedes any a priori guidance.” Missing: apply your configured per-partner default and log it.
cur optional Allowed bid currencies (ISO-4217); recommended only when more than one is accepted. Missing: whatever was agreed at integration — in writing.
bcat / acat optional Blocked or allowed advertiser categories — one, not both — read against the taxonomy in cattax, which defaults to Content Taxonomy 1.0. Sending 2.x codes without cattax silently changes their meaning.
badv / bapp optional Blocked advertiser domains and app store IDs. Missing: no restrictions.
wseat / bseat optional Allowed or blocked buyer seats; at most one of the two, omitting both means no restrictions.
source / regs / ext optional Annotated in their own sections below.

The field to stop on is at. The spec default for an absent at is 2 — second price plus. Reality moved the other way years ago: most of the ecosystem runs first price, and most partners who omit at mean first price and have stopped thinking about the field. A spec default nobody intends, on the field that decides what the winner pays, is the worst place for a silent assumption. Pin the auction type per partner at integration, set at explicitly on everything you send, and treat an omitted at you have no agreement for as a question for the partner.

On imp: an empty imp array is neither a no-bid nor acceptable input. A request with nothing for sale is malformed, and the correct answer is 400, so the sender’s error dashboards surface the bug instead of recording a string of mysterious “no-bids.”

imp: the impression being sold

Each Imp is one sellable placement. Our example has one; a request offering every slot on a page has several.

Field Spec status Meaning, and behavior when missing
id required Identifies this impression within the request — typically "1", incrementing. Required on every impression, not just multi-imp requests: a bid’s impid has nothing to match without it. A single-imp request without imp.id is malformed, full stop — “it’s obvious which impression I mean” is exactly the reasoning that produces unmatchable bids the day a partner adds a second slot.
banner / video / audio / native see note Each object present offers the impression in that format; several at once is legal, as here, and any bid must conform to exactly one. An Imp with none of the four offers nothing — reject it.
tagid optional The placement or tag that initiated the auction. Nominally debugging aid; in practice the field ops teams grep first.
instl default 0 1 = interstitial/full-screen.
secure optional 1 = creative assets must be HTTPS. Missing means unknown “but non-secure HTTP support can be assumed” — a bad assumption on today’s all-HTTPS web, where it produces mixed-content blanks. Send secure: 1; when receiving traffic without it, require HTTPS anyway.
bidfloor default 0 Minimum bid, as CPM. Missing means a floor of zero — almost always a sender misconfiguration, so accept but log.
bidfloorcur default “USD” Currency for all floors in this Imp. Trap: a Deal's bidfloorcur does not inherit from it — explicitly set or USD. Exchanges running non-USD private deals rediscover this at reconciliation.
pmp optional Private-marketplace deals; each Deal has a required id, its own floor, and optionally its own at override.
exp optional Advisory seconds between auction and impression — relevant to how long you keep the auction record alive.
ext optional Where gpid and friends live — see below.

Three format objects cover the bulk of demand, and the spec’s obligations differ sharply between them.

The modern surprise: banner.w and banner.h are not spec-required — nothing in the Banner object is. The format array of size objects is the recommended path; w/h is the legacy singular pair, suggested only when format is absent. So a slot offering 300×250 and 336×280 is one format array, as in the example. Practically: bidders read format first and fall back to w/h, because legacy-pair traffic is still everywhere, and exchanges send both for compatibility with older DSP parsers. A banner carrying neither is technically legal and effectively unbiddable — treat sizeless supply as a bug to raise, not a shrug. battr blocks creative attributes (AdCOM codes — here autoplay-audio and auto-expanding units) and pos declares position; both optional, both priced on by buyers.

video

Of the four fields every integration guide says to send — mimes, minduration, maxduration, protocols — the spec requires exactly one: mimes. The other three are recommended. That cuts both ways: a request carrying only mimes is spec-valid, so your parser must not 400 it — but demand cannot price a slot without durations and accepted VAST versions, so a working exchange insists on the recommended trio from its supply and says so in its docs. “Spec-valid” and “sellable” are different bars.

Decoding the example: protocols: [3, 6, 7, 8] is VAST 3.0 and 4.0 plus their wrapper forms (AdCOM’s audio/video subtype list). startdelay: 0 means pre-roll; positive values are mid-roll offsets in seconds. plcmt: 2 is accompanying content — the 2023 revision of 2.6 deprecated the old placement field in favor of plcmt, and buyers now price instream (plcmt: 1) sharply differently from everything else, so sending only the old field undersells honest instream. skip: 1 with skipafter: 5 declares a skippable unit; a bidder answering with skippable markup should set creative attribute 16 in bid.attr. For CTV, 2.6’s real reason to exist is the podding vocabulary — podid, podseq, slotinpod, rqddurs, maxseq, poddur — which describes an ad break rather than a slot; note rqddurs is mutually exclusive with minduration/maxduration.

native

Two fields: request (required) and ver (recommended). The trap is the type of request: a JSON-encoded string, not a nested object. The double encoding is deliberate — the payload complies with the separate Dynamic Native Ads specification and travels opaquely so the two specs can evolve independently. Sending it as an inline object is probably the most common native integration error. Always send ver, because Native 1.0 wraps the payload in a root native object and 1.1+ drops the wrapper — a parser that guesses wrong rejects everything. The response comes back the same way: a structured native response, JSON-encoded into bid.adm.

Should the request carry site, app or dooh?

Exactly one, chosen by what the inventory is: site for browser-based web content, app for non-browser applications, dooh (new in 2.6) for digital out-of-home screens. The spec is explicit that a request “must not contain more than one” of the three — two context objects is a malformed request, not a rich one. The surprise is the other direction: none of the three is required; site and app are only recommended, so a request with no context object is technically valid OpenRTB. It is also unsellable, because there is no domain or bundle to run authorization or brand-safety rules against — which is why real exchanges answer contextless requests with a 204 no-bid rather than a 400, and most demand simply won’t bid on them.

Within site, the load-bearing fields are domain and page — what buyers verify against ads.txt — and publisher.id, which the spec ties directly to transparency infrastructure: it “corresponds to a seller_id of a seller in the exchange’s sellers.json file.” If those two disagree, buyers notice before you do. Within app, the equivalent is bundle, with a format quirk: Google Play uses the package name (com.example.newsreader), the Apple App Store a numeric ID — reversing them is a classic mobile bug that quietly zeroes app-ads.txt matching. dooh swaps in a venue taxonomy, plus the qty impression multiplier back on the Imp for screens watched by more than one person.

device and geo

Everything in Device is optional by the letter of the spec (geo, dnt, lmt are recommended), but this object is where the money signals live, and demand bids accordingly.

  • ua is the raw User-Agent string; sua is its structured replacement built from User-Agent Client Hints. The spec: keep populating ua for backwards compatibility, but when both are present bidders should trust sua, because the raw string may be frozen or reduced by the browser. Detection logic reading only ua is quietly degrading.
  • ip / ipv6 carry the address closest to the device — the input for geo and IVT filtering. (The example’s 203.0.113.44 is a documentation-reserved range.)
  • ifa is the OS-issued advertising ID “in the clear,” not a hash. The six hashed device-ID fields beside it in 2.5 (didsha1, didmd5, dpidsha1, dpidmd5, macsha1, macmd5) are all deprecated in 2.6. lmt: 1 or dnt: 1 mean tracking is limited or refused; an ifa alongside lmt: 1 is a contradiction — lmt wins.
  • devicetype and connectiontype come from AdCOM lists — here 1 = mobile/tablet and 2 = WiFi.

Geo appears twice with two meanings: under device, current location; under user, home base. Three traps, all producing silently wrong targeting rather than errors:

  • country is ISO-3166-1 alpha-3"USA", not "US". Sending alpha-2 is among the most common request bugs in the wild, and a strict bidder geo-filter will never match it.
  • type declares provenance (1 = GPS, 2 = IP lookup, 3 = user-provided), and the spec directs that lat/lon only be passed when they honor that claimed accuracy — a postal-code centroid dressed as a device fix is explicitly called out as wrong, and fraud teams treat impossible combinations as a signal.
  • city is specified as a UN/LOCODE trade-and-transport code. Almost nobody sends one; real traffic overwhelmingly carries plain city names and every consumer has adapted. Target on country/region/zip; treat city as informational.

The User object describes the audience, and in 2026 it is mostly a story about which identifiers survived. user.id is the exchange’s own cookie-based ID. user.buyeruid is the buyer’s ID for the same person, placed there as the output of cookie syncing — and the single strongest predictor of whether a DSP bids at all: without a buyeruid it recognizes, open-web demand largely prices the request as anonymous or ignores it.

eids is the standardized container for everything else: each entry names a source (the ID provider’s canonical domain) and carries uids, each with the identifier and an atype from AdCOM’s agent-types list — 1 for browser/device-tied IDs, 2 for in-app device IDs, 3 for person-based cross-device IDs. Buyers increasingly require atype to route IDs; send it. Two housekeeping notes: yob and gender are deprecated in 2.6, and user.consent is the 2.6 home of the TCF consent string when GDPR applies — in 2.5 it lives at user.ext.consent, and since most of the installed base still speaks 2.5, receivers must read both for years yet. Our example is a US user with gdpr: 0, so no TCF string appears.

Worth pausing here, because device, geo and user together are the enrichment surface, and that makes them the most commercially loaded objects in the request. Almost everything a buyer uses to decide whether to bid, and how much, arrives in these three — not in the objects describing the ad slot. The asymmetry is the point: the slot is what you are selling, and these fields are what make it possible to price. A request arriving with geo resolved and user.eids populated is evaluated by more buyers, at higher prices, than the identical impression arriving without them. Same page, same placement, same moment — different auction.

The operational consequence is that these fields are worth monitoring as revenue metrics rather than as schema details. An empty geo or a missing buyeruid never raises an error, never appears in a drop report, and never looks like a fault; it just quietly earns less. When you are debugging weak demand on an endpoint, read these three objects on a real captured request before you look anywhere else — a surprising share of “this partner does not bid for us” turns out to be “we are sending them requests they cannot price.”

source and the schain

Source answers what the rest of the request cannot: who decides this auction, and what path did the request take? fd declares whether the final sale decision is the exchange’s (0) or upstream (1) — header bidding being the canonical upstream case. tid is a transaction ID meant to be “common across all participants,” the field that lets two platforms discuss one transaction without exchanging logs; pass it through untouched.

Then schain — and first, its address history: 2.6 made it a first-class field at source.schain, but in 2.5, still the most deployed version, it lives at source.ext.schain. A parser reading one location will conclude that many partners “don’t send schain” when they send it at the other address. Read both; write the one matching the version you speak.

Inside the SupplyChain object, everything is required: complete (1 only when the chain reaches back to the inventory’s owner), ver (the string "1.0"), and nodes — one per entity that touched the request, first node closest to the publisher, last node the entity sending you this request. Each node requires asi (the advertising system’s canonical domain — the same one buyers look up in that system’s sellers.json) and sid (the seller account within it, typically matching publisher.id); hp should always be 1 in chain version 1.0 and must be propagated. Two rules that get missed: name and domain should be omitted when the seller already appears in the system’s sellers.json — the file is canonical, and duplication invites contradictions — and a forwarding exchange appends its node, never rewrites the chain it received.

The schain is one leg of a three-legged verification stool with the seller’s ads.txt and each system’s sellers.json, and buyers check the three against each other. How those files are built, crawled and cross-validated — and how they silently disagree — is its own article: ads.txt and sellers.json validation.

regs: COPPA, GDPR, US privacy and GPP

Regs declares which regulatory regimes the sender believes apply, and its fields are an archaeology of privacy law. coppa (0/1) flags the US children’s-privacy statute. gdpr is a 2.6 first-class flag — 1 means GDPR applies and a TCF string belongs in user.consent; omission means unknown, which is not 0 and should not be collapsed into it. us_privacy carries the legacy CCPA-era string ("1YNN": version 1, notice given, no sale opt-out, no LSPA).

The present tense is GPP: gpp holds one packed string carrying multiple jurisdictions’ signals as sections, and gpp_sid says which section IDs apply to this transaction — generally exactly one, and never sections 3 or 4, which are the string’s own header and integrity machinery. The example’s gpp_sid: [7] selects the MSPA US-national section; a GDPR request would carry section 2, EU TCF v2. (The example string is truncated and does not decode; real GPP strings start with D, which distinguishes them from a bare TCF v2 string’s C.) As with schain, 2.5 traffic carries these signals at old addresses — regs.ext.gdpr, regs.ext.us_privacy — so read both.

The operating rule for this object and the strings it points to: pass them through byte-for-byte. An exchange that drops or “normalizes” a consent string makes every downstream partner non-compliant on its behalf, and nothing in your metrics will say so. Any code path that mutates a regulatory string is a defect.

What is imp.ext actually for?

Every object in OpenRTB ends in an ext field the spec defines identically: “placeholder for exchange-specific extensions to OpenRTB.” The non-definition is deliberate — ext is the pressure valve that lets the ecosystem ship features between spec revisions — and it means the contents of any given ext are set by bilateral agreement or by whichever intermediary put them there, not by the IAB. That is why imp[0].ext is the field most often being stared at in a captured request: its meaning genuinely is not in the spec.

Some conventions have hardened into quasi-standards. imp.ext.gpid — the Global Placement ID in our example — gives buyers one stable placement identifier across every path an impression reaches them by, and grew out of the Prebid ecosystem. Traffic that transited Prebid Server carries imp.ext.prebid with bidder parameters; consent strings from 2.5 traffic live in ext blocks as described above. Beyond those, expect proprietary signals — identity vendors, viewability scores, whatever a partner shipped last quarter.

The engineering rule has teeth: never fail validation on an unknown ext. A validator that rejects unrecognized properties inside ext breaks every time any partner ships a feature — which is constantly — and each break looks like the partner’s bug while being yours. Parse what you understand, pass through or deliberately strip the rest, and count what you saw, so “are you reading our ext.foo?” is answered by a metric rather than a code read.

The bid response, annotated

The response to the request above, carrying one banner bid:

{
  "id": "b5ba5ed2-547e-4e86-8a84-34a440dad6db",
  "bidid": "resp-7f3d9c",
  "cur": "USD",
  "seatbid": [
    {
      "seat": "seat-2201",
      "bid": [
        {
          "id": "bid-9a417e",
          "impid": "1",
          "price": 2.4,
          "adomain": ["advertiser-brand.example.org"],
          "cid": "camp-3341",
          "crid": "cr-90210",
          "w": 300,
          "h": 250,
          "mtype": 1,
          "adm": "<div class=\"ad\"><a href=\"https://click.dsp.example.net/c?cid=camp-3341\"><img src=\"https://cdn.dsp.example.net/cr-90210.jpg\" width=\"300\" height=\"250\" alt=\"\"></a><img src=\"https://dsp.example.net/px?p=${AUCTION_PRICE}\" width=\"1\" height=\"1\" alt=\"\"></div>",
          "nurl": "https://dsp.example.net/win?auction=${AUCTION_ID}&imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
          "burl": "https://dsp.example.net/bill?auction=${AUCTION_ID}&price=${AUCTION_PRICE}",
          "lurl": "https://dsp.example.net/loss?auction=${AUCTION_ID}&reason=${AUCTION_LOSS}"
        }
      ]
    }
  ]
}

Three nested layers. BidResponse.id is required and echoes the request’s id — a response that doesn’t match the request it answers is unattributable; drop it. bidid is the bidder’s optional tracking ID, which the exchange can carry into the win notice. cur defaults to USD; a bid in a currency the request’s cur didn’t allow is outside the auction’s terms and discarded.

seatbid is required “if a bid is to be made” — one entry per buyer seat, each with a required bid array and an optional seat identifier; multiple bids from one seat on one impression are legal and common. Within each Bid, exactly three fields are required: id; impid, which must match an imp.id from the request — the join the whole protocol hangs on; and price, a CPM float the spec pointedly recommends handling with integer or decimal math, because binary floats and money reconcile badly. The rest is ad-quality surface the exchange should insist on even though the spec does not: adomain for block lists, crid/cid for creative review, w/h to confirm the size matches an offered format, dealid when claiming a private deal, and mtype (1 = banner, 2 = video, 3 = audio, 4 = native) declaring which offered format the markup is. On a multi-format impression like ours, a bid without mtype is ambiguous; exchanges either infer from markup — fragile — or require the field, which is the better answer.

To decline the request, a bidder has two clean options: an empty-bodied HTTP 204, or a BidResponse with only id and an nbr no-bid reason code. The receiver’s side has a robustness rule worth copying: a malformed response, or one containing no actual bids, is simply treated as a no-bid — response-side strictness never takes down the auction; it just costs the sloppy bidder the impression.

Are nurl and burl the same thing?

No, and treating them as synonyms is how exchanges overstate revenue. nurl is the win notice: called when the bid wins, and — quoting the spec — “not necessarily indicative of a delivered, viewed, or billable ad.” A win notice can fire before the creative renders, or for an ad that never renders at all. burl is the billing notice: called “when a winning bid becomes billable based on exchange-specific business policy,” typically at delivery. The protocol keeps two URLs so won and owed stay separate events, because in production they diverge constantly — auctions are won and pages abandoned, creatives fail, timeouts land in the gap. Count wins on nurl if you like; bill and reconcile on burl.

Three pieces of machinery complete the picture:

  • nurl doubles as an ad-serving channel. A bidder may omit adm, in which case the exchange fetches markup as the response body of the nurl call — that body must contain the markup and nothing else. When both adm and a nurl return are present, adm wins. The trade: markup-on-win saves sending creatives for the vast majority of bids, which lose; markup-in-bid removes the risk of winning and then forfeiting to a failed HTTP call.
  • lurl is the loss notice, with a spec-sanctioned caveat: exchanges may decline to support loss notices, or strip ${AUCTION_PRICE} from them to avoid disclosing clearing prices to losers. Build nothing that assumes losses are always reported.
  • Billing policy is a contract, not a guess. The spec’s best-practice notes: fire the billing notice server-side, as close as possible to where revenue is booked; and for VAST video, the VAST impression event — not the win notice — is the official billable signal, with burl fired alongside it. Which event means “money moved” must be agreed per integration, because it is the number reconciliation runs on.

The failure modes this machinery produces in production — auction records expiring before slow notices arrive, unsubstituted macros landing in accounting, a broken win loop presenting as a demand problem — are the settlement-clock material of the RTB exchange setup guide, which picks up exactly where this section stops.

The substitution macros

Before firing a nurl, burl or lurl — and before serving markup — the exchange replaces macros with live values. The core set:

Macro Substituted with
${AUCTION_ID} BidRequest.id
${AUCTION_BID_ID} BidResponse.bidid
${AUCTION_IMP_ID} The imp.id just won
${AUCTION_SEAT_ID} The winning seat’s ID
${AUCTION_AD_ID} bid.adid, when a preloaded ad was referenced
${AUCTION_PRICE} The clearing price, in the bid’s own currency and units
${AUCTION_CURRENCY} The bid currency, for confirmation
${AUCTION_MBR} Market bid ratio: clearing price ÷ bid price
${AUCTION_LOSS} A loss reason code (in lurl)
${AUCTION_MIN_TO_WIN} The minimum bid that would have won

The rules around the table matter as much as the table. Substitution is dumb text replacement — “without regard for syntax correctness” — and a macro whose source is an optional field that was not sent is replaced with a zero-length string, not left literal; bidder parsers should expect empty parameters. Compliant exchanges must support every macro for which they have data, in both notice URLs and markup — the markup case is how a bidder gets win confirmation from the device itself, via a pixel like the one in the example’s adm. Two conventions round it out: when rendering markup for testing or creative review, "AUDIT" stands in for values like the clearing price that don’t yet exist; and macros can be encoded by mutual agreement with a suffix syntax — ${AUCTION_PRICE:B64} — for prices that transit the page on the way back to the bidder.

The validation checklist a working endpoint enforces

Everything above compresses into the rule set an inbound bid endpoint actually runs. The operational framing — why the 400/204 line must stay clean — is in the exchange setup guide; this is the schema layer it assumes.

Answer 400 — the payload is malformed:

  • Body does not parse as JSON, or violates declared types.
  • id missing.
  • imp missing or an empty array.
  • Any imp without an id, or with a duplicate id.
  • An imp offering none of banner, video, audio, native.
  • video without mimes — the one field the spec requires of the object.
  • native.request missing, or an object instead of a JSON-encoded string.
  • More than one of site, app, dooh.
  • A schain missing complete, nodes or ver, or a node missing asi/sid.
  • Negative or non-numeric bidfloor.

Answer 204 — valid OpenRTB you will not transact:

  • No site, app or dooh at all: legal per spec, but nothing to authorize or apply rules against.
  • A seller you do not recognize, a publisher.id absent from your records, a category or geo you refuse.
  • A tmax too small to run your auction in.

Normalize with a log line, never silently:

  • Absent at → the per-partner agreed auction type, never a global constant — the spec default of 2 is not what most senders mean.
  • Absent tmax → your configured default. Absent bidfloorcur → USD, per spec. Absent secure → require HTTPS regardless.
  • geo.country in alpha-2 → normalize to alpha-3, and tell the partner.

Pass through untouched, always:

  • Consent and privacy strings — gpp, us_privacy, TCF — byte-for-byte.
  • The schain you received (append your node; never rewrite).
  • Every ext you do not consume.

Where Floxis fits

This article is the schema half of a job whose other half never ends — the exchange setup guide covers that operating loop, from timeout budgets to win-notice reconciliation. Floxis is a white-label RTB exchange you run as your own: the protocol surface annotated above is what it already speaks — OpenRTB 2.5 and 2.6, Prebid, VAST and JS tags, with custom adapters built and maintained for partners who speak none of them — running a 200–300 ms DSP bid timeout in production, with every bid, win and drop landing in your reporting as log-level data. If you would rather operate an exchange than build a parser for this spec, request a walkthrough.

Sources and standards worth bookmarking