OpenRTB for publisher and SSP engineers
On the sell side you own the bid request: what it says about the impression, how accurate that is, and how quickly it leaves. You also run the auction and fire the notices that bidders count spend against. This page lists those duties, the fields buyers price on, the failures that cost revenue, and how to test before a partner goes live.
What a sell-side engineer owns
- The request contract. One complete
BidRequestper auction, valid for the version you declare inx-openrtb-version, with exactly one ofsite,app, ordooh. - Inventory identity. Domains, bundles, store URLs, and publisher IDs that agree with ads.txt, app-ads.txt, and your sellers.json.
- Consent and identity. Privacy strings in their 2.6 paths, and user identifiers passed only where those strings allow.
- Supply chain. A
source.schainthat lists every hop, with your own node last. - Price rules. Floors, floor currencies, deals, and the auction type.
- Time. A
tmaxthat reflects what is left of your own budget, and a fan-out that calls bidders in parallel. - The auction. Checking bids against the request, applying blocks, and setting the clearing price.
- Notices and documentation. Firing
nurl,burl, andlurlwith macros expanded, and publishing what each event means and whichextfields you send.
A request that answers the buyer's questions
Before pricing, a buyer wants to know what the impression is, where it runs, who sees it, which companies sit between it and the publisher, and what it costs. This in-app video interstitial answers each of those:
{
"id": "6c1f0b9e-3a27-4d55-8e0c-b4a2d97f1e63",
"at": 1,
"tmax": 250,
"cur": ["EUR", "USD"],
"bcat": ["IAB25", "IAB26"],
"imp": [
{
"id": "1",
"tagid": "level-end-interstitial",
"instl": 1,
"secure": 1,
"exp": 1800,
"bidfloor": 3.00,
"bidfloorcur": "EUR",
"video": {
"mimes": ["video/mp4"],
"plcmt": 3,
"minduration": 5,
"maxduration": 30,
"protocols": [7, 8],
"w": 1080,
"h": 1920,
"skip": 1,
"skipafter": 5
},
"pmp": {
"private_auction": 0,
"deals": [
{ "id": "dl-games-eu-q4", "bidfloor": 6.50, "bidfloorcur": "EUR", "at": 1, "wseat": ["agency-311"] }
]
}
}
],
"app": {
"id": "app-5531",
"bundle": "com.example.puzzlequest",
"storeurl": "https://play.google.com/store/apps/details?id=com.example.puzzlequest",
"publisher": { "id": "pub-2290" }
},
"device": {
"ua": "Mozilla/5.0 (Linux; Android 14; SM-S921B Build/UP1A.231005.007; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile Safari/537.36",
"ip": "203.0.113.0",
"devicetype": 4,
"os": "Android",
"osv": "14",
"ifa": "3f6e1c2a-9b84-4d07-a5e2-6c1d8f0b7a93",
"lmt": 0,
"geo": { "country": "DEU", "type": 2 }
},
"user": { "consent": "CPXxRfAPXxRfAAfKABENB" },
"regs": { "coppa": 0, "gdpr": 1, "gpp": "DBABMA~CPXxRfAPXxRfAAfKABENB", "gpp_sid": [2] },
"source": {
"tid": "9d0a4f7e-61c2-4b3e-8f5d-2e7c90a1b6d4",
"schain": {
"ver": "1.0",
"complete": 1,
"nodes": [
{ "asi": "mediation.example", "sid": "pub-2290", "hp": 1 },
{ "asi": "ssp.example", "sid": "acct-88140", "hp": 1, "rid": "6c1f0b9e-3a27-4d55-8e0c-b4a2d97f1e63" }
]
}
}
}The media object says what: a video interstitial with imp.video.plcmt 3 (Interstitial), 5 to 30 seconds, and VAST 4.0 inline or wrapper. app.bundle and app.storeurl say where. The device, consent, and identifier fields say who, and the two-node chain shows a mediation platform selling through this SSP. The open floor is 3.00 EUR and the deal floor is 6.50 EUR.
Two details are easy to miss. imp.exp of 1800 warns bidders that the ad may play up to 30 minutes after the auction, which is honest for an ad shown at the end of a game level. And the deal repeats bidfloorcur because Deal.bidfloorcur does not inherit from the impression; left out, it would default to USD.
The fields buyers price on
| Fields | What buyers use them for | Typical sell-side failure |
|---|---|---|
imp.video.plcmt, imp.banner.format | Format and placement targeting | The old placement integer copied into plcmt; one fixed size sent when several fit |
site.page, app.bundle | Brand safety, app and domain targeting, ads.txt checks | Bundle missing, or set to an internal ID |
publisher.id, schain sid | Matching the seller in sellers.json | An internal alias instead of the account ID sellers.json lists |
device.devicetype, device.ip, device.sua | Device targeting and invalid traffic checks | CTV sent as a personal computer; the server's IP instead of the address closest to the device |
device.ifa, user.buyeruid, user.eids | Frequency capping and audience matching | Identifiers sent while consent says no |
regs.gdpr, user.consent, regs.gpp, regs.gpp_sid | Whether the buyer may use the data at all | Pre-2.6 ext paths; gpp without gpp_sid |
source.tid, source.schain | Deduplication and supply path checks | complete set to 1 while an upstream hop is missing |
imp.bidfloor, imp.bidfloorcur, imp.pmp.deals | Pricing, shading, and deal routing | Deal floors read in the wrong currency |
at, tmax, cur | The rules of the auction | at left at its default of 2 in a first-price auction |
Consent signals and the supply chain
Put privacy signals where 2.6 defines them: regs.gdpr, user.consent, and the regs.gpp and regs.gpp_sid pair. Buyers that read only the 2.6 paths see no signal at all when you leave them under ext. Then gate identifiers on those signals before fan-out. When GDPR applies, pass IDs only for vendors with consent. When a GPP section signals an opt-out, or regs.coppa is 1, empty the eids array. A structurally valid request can still break the law, so test the gating logic as well as the schema. The privacy reference maps every signal.
For the chain, append your own node with your sellers.json domain as asi and the account you pay as sid. Set complete to 1 only when the first node is the inventory owner's own system. Buyers walk every node against sellers.json and the publisher's ads.txt or app-ads.txt, and they answer failed checks with nbr 11 or 12 for ads.txt and 16 or 17 for the chain. The supply chain reference has the node rules.
Pricing rules
Send at explicitly. Its default is 2, second price plus, so a first-price seller that omits it tells every bidder to bid the wrong way. List the currencies you accept in cur, and keep in mind that bidfloorcur is separate: you may floor in EUR and accept USD, in which case bidders convert before comparing. Deal floors carry their own currency, and a deal with at 3 turns its floor into a fixed price. Duration floors for pods live in imp.video.durfloors. The floors reference and the deals guide cover the details.
The time budget
Your auction usually runs inside someone else's timeout: a header bidding wrapper, a mobile mediation SDK, or an upstream exchange. Compute tmax per request as the budget you have left minus the work you still do after bidders answer (running the auction and replying to your caller). Never raise a tmax you received, and always send milliseconds. A value like 300000 is a unit bug that bidders either clamp or reject.
Close each auction on time even when bidders are still pending, and call bidders in parallel over persistent connections with gzip. If bidders keep answering with nbr 15 (Insufficient Auction Time), your budget is too thin for the routes you send it over. The auction lifecycle page shows where the milliseconds go.
Running the auction and firing notices
Treat every bid response as untrusted input and check it against the request that produced it. BidResponse.id must echo your request ID. Each impid must name an impression you offered, the currency must be in your cur list, the seat must be allowed by wseat or the deal's wseat, a dealid must name a deal you sent, and mtype must be a format the impression accepts. Drop bids that fail, and send the matching loss reason (codes 1 to 10, 104, or the 200 range) when the bidder asked for loss notices.
After the auction, expand macros and fire the notices. nurl goes to the winner once it is chosen. burl should fire server-side at the billable event, close to where you book revenue. lurl carries ${AUCTION_LOSS} and, where your policy allows, ${AUCTION_MIN_TO_WIN}. Replace unknown optional values with an empty string, and use AUDIT when rendering for review. Then write down what triggers each notice. The spec asks exchanges to publish their event triggers and billing policy, and most buyer-side discrepancies trace back to a missing answer here. The macro reference has the full table.
Failure modes that cost revenue
- Silent rejection. A bidder that cannot parse your request returns 204, or
nbr2 if you are lucky. You see a low bid rate, not an error. Tracknbr2 per bidder and treat a rise as a release regression. - Enrichment drift. Fields added late in the pipeline (consent, geo, identifiers) disappear when one service changes. Validate requests after the last enrichment step, not the template.
- Stale paths. Old SDKs keep sending
regs.ext.gdpr,source.ext.schain, orvideo.placement. Normalize them before fan-out. - Wrong defaults. An absent
at,bidfloorcur, orsecuremeans something specific. Make every value you intend explicit. - Chain overclaims.
completeset to 1 with an unknown first hop gets the whole path discounted by buyers that check it.
The ten most frequent request bugs are in common OpenRTB mistakes.
How to test
- Capture fixtures after enrichment, one per inventory type you sell (web, app, CTV, DOOH) and per consent state (GDPR applies, GPP present, COPPA set). Validate them in CI against the release you declare; the CI guide shows the setup.
- Run live checks with partners using
testset to 1, which makes the auction non-billable on both sides. - Ask each bidder to return
nbrcodes during integration, and review the reasons before switching to production traffic. - Validate responses against the request you sent, using the same checks your auction applies, so a bidder bug shows up as a test failure and not as lost revenue.
- Where a buyer publishes its own requirements, check your requests against them too; the exchange notes collect documented dialects.
To see what a complete consent-bearing request looks like in a validator, open the mobile banner with consent sample in the tester and replace it with one of your captured requests.
Questions people ask
Which fields should an SSP always send in an OpenRTB bid request?
Beyond the required id, imp, imp.id, and a media object, send exactly one of site, app, or dooh with its domain or bundle and a publisher ID; a device object with ua, ip, and devicetype; consent signals in their 2.6 paths under regs and user; source.tid and source.schain; and explicit at and tmax values. The spec marks most of these recommended or optional, but buyers rarely bid well without them.
Should an SSP reduce tmax before sending a bid request to bidders?
Yes. tmax is measured at the exchange and includes network latency, so a seller that received a time budget from upstream should forward a smaller value after subtracting its own processing and the extra network hop. It should never increase the value it received, and it must always send milliseconds.
Does a deal floor inherit the impression's floor currency?
No. Deal.bidfloorcur does not inherit from imp.bidfloorcur. It defaults to USD on its own, so a deal priced in euros must set bidfloorcur to EUR on the deal object itself, or bidders will read the deal floor as dollars.