DEV Community

Aleksander Sekowski
Aleksander Sekowski

Posted on

Top 10 OpenRTB Mistakes That Are Costing You Money

OpenRTB has a property that makes it uniquely painful to debug: almost nothing in it fails loudly. The spec tells parsers to ignore unknown fields. The version travels in an optional HTTP header most parties drop. A malformed bid request does not bounce with a 400; it just gets no-bid by every partner that enforces the rule you broke, while lenient partners keep the traffic flowing and convince you everything is fine.

That is the revenue mechanic behind every mistake on this list. Each broken field removes some set of bidders from the auction, and every removed bidder lowers your clearing price. You do not see an error. You see CPMs that are a little worse than they should be, forever.

I build RTBlint, an open source OpenRTB linter, and these are the ten failures I see most in real traffic. Each one maps to a stable rule ID, so you can catch all of them in CI instead of in a quarterly revenue review.

1. Strings where the spec says integers

{ "at": "1", "tmax": "120", "test": "0" }
Enter fullscreen mode Exit fullscreen mode

Every one of those values is wrong. at, tmax, test, and the various flag fields are integers. JSON has real numbers, but request builders that template their way through string concatenation ship quoted digits, and a typed parser (Go structs, Java POJOs, anything protobuf-backed) rejects the entire request. Not the field. The request.

This is the most expensive mistake on the list because it costs you 100% of the bidders that parse strictly, and those tend to be the larger, better-engineered ones.

RTBlint flags every occurrence as openrtb.type.mismatch with the exact JSON path.

2. An impression that never says what is for sale

Each object in imp[] must carry at least one of banner, video, audio, or native. An imp with none of them is an auction for nothing: the bidder has no idea what creative form is acceptable, so it does not bid. This usually comes from a request builder that assembles imp objects conditionally and has a branch where every condition is false.

Rule: openrtb.imp.media_type.required.

3. Still sending video.placement instead of plcmt

The 2.6-202303 update deprecated imp.video.placement in favor of imp.video.plcmt, with values from the AdCOM Plcmt Subtypes list: 1 instream, 2 accompanying content, 3 interstitial, 4 no content / standalone. The two enums do not map one to one, so this is a re-declaration, not a rename.

Why it costs money: buyers now key their instream vs outstream classification off plcmt, and instream commands a multiple of outstream pricing. If you sell instream inventory but only declare the old placement field, buyers that follow the current spec classify you as undeclared and price you like outstream.

Rule: openrtb.field.deprecated, three years running the most common finding in video traffic.

4. Privacy signals stuck at their 2.5 paths

OpenRTB 2.6 promoted the GDPR signals into core objects:

2.5 era 2.6
regs.ext.gdpr regs.gdpr
user.ext.consent user.consent
source.ext.schain source.schain

Requests built on 2.5-era code keep writing the ext paths, and 2.6-native consumers never look there. The failure mode for privacy fields is worse than a lost bid: a buyer that cannot find a GDPR signal on EU traffic has two options, drop the request or bid non-personalized. Both pay you less. Some legal teams mandate the first.

Rule: openrtb.field.moved, which also tells you the replacement path.

5. Half a GPP pair

regs.gpp carries the Global Privacy Platform consent string and regs.gpp_sid says which GPP sections apply. They only mean something together. A gpp string without gpp_sid cannot be interpreted (which section governs this user?), and a gpp_sid without the string is an empty claim.

As US state privacy laws multiply, buyers increasingly treat an uninterpretable consent signal the same way they treat a missing one: suppress personalization or skip the request.

Rules: openrtb.regs.gpp_without_gpp_sid and openrtb.regs.gpp_sid_without_gpp.

6. site and app in the same request

A bid request must contain at most one of site, app, or dooh. They answer the same question about what kind of inventory this is, and a request carrying two of them is ambiguous, so many bidders drop it outright. The usual cause is a request builder that merges publisher defaults with placement data and keeps both branches.

Rule: openrtb.fields.mutually_exclusive.

7. Floors in a currency you did not declare

imp.bidfloor is a CPM, and imp.bidfloorcur is its currency, defaulting to USD. It does not inherit from cur or from your account settings or from anything else.

A floor of 120 that you meant as Japanese yen but sent without "bidfloorcur": "JPY" reads as a 120 USD floor. Every bid loses to it. Your fill for that placement goes to zero and nothing anywhere tells you why. The mirror image also happens: a floor meant in USD declared in a minor currency invites bids far below what you intended.

Rules: openrtb.imp.bidfloorcur_format_invalid for malformed currency codes, openrtb.imp.bidfloor_negative for negative floors.

8. A tmax nobody can meet

tmax is the total time in milliseconds you allow for bids to arrive, network latency included. Two failure modes show up in traffic:

  • tmax: 0 or a negative value, which technically gives bidders no time at all. Rule: openrtb.request.tmax_non_positive.
  • tmax: 300000, which is five minutes and means someone is sending seconds. Bidders either clamp it or distrust the request. Rule: openrtb.request.tmax_implausible, which fires above 10,000 ms.

Either way you distort the auction: too low and slow-but-high-paying bidders never make it back, too high and you are the exchange whose requests look broken.

9. A supply chain that fails audit

Buyers cross-check source.schain against sellers.json and ads.txt before spending. Three defects break that audit:

  • The same asi/sid node appearing twice: openrtb.schain.duplicate_node
  • A node missing the required hp flag: openrtb.schain.node.hp_missing
  • Empty asi or sid strings, which make the node unverifiable: openrtb.schain.node.identifier_empty

An unverifiable supply chain is indistinguishable from a suspicious one, and post-adselect, buyers do not give the benefit of the doubt. They route spend to paths they can verify.

10. Bid responses that contradict themselves

Everything above is the request side. The response side has its own money leaks, and they hit DSPs and bidders instead of publishers:

  • adm present but mtype missing, so the exchange cannot classify the creative: openrtb.bid.mtype_missing
  • VAST XML or native JSON that was JSON-encoded twice, arriving as a string of escaped quotes no player can render: openrtb.bid.adm.double_encoded
  • A native bid whose adm is not parseable JSON: openrtb.bid.adm.native_not_json
  • An impid that does not match any imp in the request, which wins nothing ever: openrtb.bid.impid_unknown
  • Bidding in a currency the request did not allow: openrtb.response.cur_not_allowed

These are the worst kind of expensive because you paid to get here: QPS, bid computation, sometimes even the win, and then the creative fails to serve and you get billed with nothing rendered. RTBlint validates responses on their own or cross-checked against the originating request, where every bid's impid, mtype, markup, deal ID, seat, and currency are verified against what the request actually offered.

Catching all of this before it ships

Every finding above has a stable rule ID, a severity, a message, and a JSON path, which means you can gate a deploy on it:

cargo install rtblint
rtblint validate request.json
rtblint validate --type response --request request.json response.json
rtblint validate --version 2.6-202606 --format json request.json
Enter fullscreen mode Exit fullscreen mode

Or from Node via WASM:

import { validate } from "rtblint-core";

const report = validate(JSON.stringify(bidRequest), "2.6-202505");
for (const issue of report.issues) {
  console.log(issue.id, issue.path, issue.message);
}
Enter fullscreen mode Exit fullscreen mode

There is also a browser tester that runs the same rules client-side (your payloads never leave the page), an MCP server if you want your coding agent to validate bid requests while it works, and the full rule reference with fix guidance per rule. The validator covers every OpenRTB snapshot from 2.0 through 2.6-202606, plus 3.0.

Source is on GitHub. If you run traffic through it and hit a false positive, an issue with the payload shape is the fastest way to get it fixed.

The pattern behind all ten mistakes is the same: OpenRTB does not tell you when you are wrong, it just pays you less. A linter in CI is the cheapest way to find out first.

Top comments (0)