OpenRTB for DSP and bidder engineers
On the buy side you own the answer. A bidder receives requests it did not design, decides inside tmax whether to bid and at what price, and returns a bid the exchange must be able to accept. Then it has to learn from the notices that come back. This page lists what a bidder engineer owns, the fields that drive the decision, the failures that lose auctions, and how to test.
What a bidder engineer owns
- An endpoint that always answers in time. A bid, an empty 204, or a reasoned no-bid, before
tmaxruns out at the exchange. - A tolerant parser. It accepts every version and dialect your exchanges send and ignores what it does not recognize.
- The request's rules. Currencies, seat lists, block lists, floors, deals, formats, and privacy signals.
- Acceptable bids. Correct IDs, price and currency, the markup and its declared type, and the fields exchanges use for creative review.
- Notice endpoints. Fast and idempotent win, billing, and loss handlers, with spend counted on billing.
- Feedback loops. Loss reasons, minimum-to-win prices, and per-exchange timeout and error rates fed back into bidding.
Parse defensively
The 2.6 spec requires both sides to handle new fields and enumerated values they do not know without failing. Ignore unknown keys and keep unknown list values as unknown, never as errors. Every object can carry an ext object whose contents only the sending exchange documents, so parse the extensions you have agreed on and skip the rest.
Expect old and new paths side by side for years. Read regs.gdpr before regs.ext.gdpr, source.schain before source.ext.schain, and imp.video.plcmt before the deprecated placement, which uses a different list. Apply spec defaults when a field is absent: at 2, imp.bidfloor 0, imp.bidfloorcur USD, test 0. Without a default, absent means unknown. Remember that imp.native.request is a JSON document inside a string and needs a second parse.
Decide in advance what a type error does. A tmax sent as "120" is the seller's bug, but rejecting it loses the impression and coercing it hides the bug. A common choice is to coerce, then log the error per exchange so the seller can be told. For a payload that cannot be parsed at all, the spec suggests HTTP 400 with no body. The x-openrtb-version header only names the version line, so infer the dated release from the fields you actually receive.
The fields that drive the decision
| Question | Fields to read |
|---|---|
| May I bid at all? | test, regs.coppa, regs.gdpr with user.consent, regs.gpp with regs.gpp_sid, wseat, bseat |
| Which creatives fit? | imp.banner.format, imp.video.mimes, imp.video.protocols, imp.video.maxduration, imp.secure, imp.banner.battr, bcat, badv |
| Where does it run, and for whom? | site.page or app.bundle, device.devicetype, device.geo, user.buyeruid, user.eids |
| Who sold it? | source.schain, source.tid, source.fd |
| What does it cost, and how is it priced? | at, imp.bidfloor, imp.bidfloorcur, imp.pmp.deals, cur |
| How much time do I have? | tmax for the answer, imp.exp for the gap before the impression |
Filter on your side what the request already blocks. Bidding a blocked category, advertiser, or creative attribute only earns a 200-range loss code. The full list of obligations a request puts on a bidder is in how to build an OpenRTB bidder.
Answer inside tmax
tmax is measured at the exchange and includes the network both ways. Set an internal deadline per exchange: tmax minus your measured round trip to that exchange, minus a margin for the slow tail. When the deadline arrives, stop and return a no-bid. A late bid is worth exactly as much as no answer, and it still costs compute. If your own sub-auction cannot fit in what is left, nbr 15 (Insufficient Auction Time) tells the exchange why.
Measure p99, not the mean, because timeouts live in the tail. Keep connections open, return gzip when the exchange sends Accept-Encoding: gzip, and run in the same region as the exchanges you buy from. The debugging steps are in tmax and bidder timeouts.
Build a bid the exchange accepts
This bid answers the in-app interstitial request shown on the SSP engineers page. It bids on the deal, in euros, with a VAST 4.0 wrapper:
{
"id": "6c1f0b9e-3a27-4d55-8e0c-b4a2d97f1e63",
"bidid": "r-55a1c7",
"cur": "EUR",
"seatbid": [{
"seat": "agency-311",
"bid": [{
"id": "b-1",
"impid": "1",
"price": 7.20,
"dealid": "dl-games-eu-q4",
"mtype": 2,
"adm": "<VAST version='4.0'><Ad id='cr-4471'><Wrapper><AdSystem>dsp.example</AdSystem><Impression><![CDATA[https://dsp.example/imp?b=${AUCTION_BID_ID}]]></Impression><VASTAdTagURI><![CDATA[https://ads.dsp.example/vast/cr-4471]]></VASTAdTagURI></Wrapper></Ad></VAST>",
"adomain": ["puzzlerival.example"],
"crid": "cr-4471",
"cat": ["IAB9-30"],
"protocol": 8,
"dur": 15,
"w": 1080,
"h": 1920,
"exp": 3600,
"burl": "https://dsp.example/bill?b=${AUCTION_BID_ID}&p=${AUCTION_PRICE}",
"lurl": "https://dsp.example/loss?b=${AUCTION_BID_ID}&r=${AUCTION_LOSS}&m=${AUCTION_MIN_TO_WIN}"
}]
}]
}BidResponse.idechoes the request ID. The bidder's own tracking ID goes inbidid.BidResponse.curis EUR, which the request allowed. Without it the price would be read as USD.impidnames an offered impression, anddealidnames a deal the request sent. The seat is on that deal'swseatlist, and 7.20 clears the 6.50 EUR deal floor.mtype2 says video, which the impression offered.protocol8 (VAST 4.0 wrapper) is in the request'sprotocols, anddur15 falls inside 5 to 30 seconds.adomain,crid, andcatare optional in the spec but expected by exchanges for creative review.exp3600 says the bidder will honor the bid for an hour, longer than the 30 minutes the request warned about.burlcarries${AUCTION_PRICE}andlurlcarries${AUCTION_LOSS}, so both notices report something useful.
Markup goes in adm or is served from nurl, and adm wins if both carry it; see adm vs nurl. With VAST, a valid bid is only half the contract: the player reads the XML inside adm, and that document needs checks of its own.
To pass on an impression, send one of these:
HTTP/1.1 204 No Content
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": "6c1f0b9e-3a27-4d55-8e0c-b4a2d97f1e63", "nbr": 8 }Use 204 for routine passes and a reason code while integrating, so the exchange can see why you pass. The codes are in no-bid reason codes.
Price on the win notice and count spend on the billing notice
nurl means you won this exchange's auction. burl means the impression became billable under the exchange's policy, usually on render or, for VAST, at the impression event. lurl means the exchange knows you lost. Count spend and frequency on burl, and feed nurl to pricing.
Make notice handlers fast and idempotent, because exchanges may retry billing calls and the same notice can arrive twice. Accept an empty ${AUCTION_PRICE}, since policy can blank it, and decode the encoded form if you agreed on one with a suffix such as :B64. Never assume the clearing price equals your bid: second-price auctions lower it, and since 2.6-202606 so can a seller discount. Track the delay between auction and billing notice. The implementation guide's section on impression expiration says a bidder that sees long delays often may ignore those notices and raise them with the exchange.
Use the feedback
Loss codes tell you who owns a lost auction. Codes 1 to 10 mostly point at a problem in the response itself, 100 to 104 are economics, and the 200 range is creative filtering. Track them separately, per exchange. Where an exchange fills ${AUCTION_MIN_TO_WIN}, use it to calibrate shading in first-price auctions instead of guessing. Ask each exchange for its timeout and parse error rates, because those failures never reach you as a notice. The ranges are explained in loss reason codes, and shading in first-price vs second-price.
Failure modes that lose auctions
- Wrong response ID. Returning
bididor a fresh value inidgets the response discarded (loss code 5). - Silent currency. Pricing in EUR without
cur, or in a currency the request did not list. - Broken markup encoding.
admescaped twice, native markup that is not JSON, or VAST without aVASTroot element. - Missing or wrong mtype. The exchange cannot tie the bid to the right media object, and multi-format impressions make this common.
- Insecure assets. HTTP creative URLs on an impression with
secure1 (loss code 207). - Stale deals. A
dealidpaused on the exchange side keeps receiving bids (loss code 4). - Tail latency. Garbage collection pauses or cold caches push the slow percentiles past the deadline.
How to test
- Collect real requests from every exchange, strip user identifiers, and keep them as fixtures. Include awkward ones: unknown fields,
extblocks, pre-2.6 paths, and multi-format impressions. - In CI, validate each response your bidder produces against the request it answers: ID echo,
impid, currency, seat, deal,mtype, and markup type. The bidder testing tutorial walks through the setup. - Load test at the exchange's expected request rate with the internal deadline enforced, and report p99 latency.
- Run integration traffic with
testset to 1, and ask the exchange for its parse error and timeout reports before going live. - Check documented exchange requirements, which often go beyond the spec; the exchange notes collect them.
To see the response checks fail on purpose, open the mismatched pair sample in the tester: wrong ID, disallowed currency and seat, banner markup on a video-only impression, and an unknown deal.
Questions people ask
How should a bidder say no bid in OpenRTB?
Return HTTP 204 with an empty body, which is the cheapest answer. To tell the exchange why, return HTTP 200 with a BidResponse that carries the request id and a reason code in nbr, for example 8 for Unmatched User. For a payload that cannot be parsed at all, the spec suggests HTTP 400 with no body; agree with each exchange whether it prefers that or nbr 2.
Which currency is a bid in if BidResponse.cur is missing?
USD, because BidResponse.cur defaults to USD. A bidder that prices in another currency must set cur explicitly, and that currency must be one of those listed in the request's cur array. A EUR price sent without cur is read as dollars.
Should a DSP count spend on the win notice or the billing notice?
On the billing notice. nurl fires when the bid wins the exchange's auction, which does not mean the ad rendered or will be billed. burl fires when the impression becomes billable under the exchange's policy, so it is the event to decrement budgets against. Use nurl as a signal for pricing models.