Level 0 — 1 (Student / Beginner)
- What header bidding is and the waterfall problem it replaced.
- Core vocabulary: wrapper, adapter, bid, floor, key-value.
- How the auction flow connects SSP, DSP, and the ad server.
Learning Track — Multi-level
From “what is a waterfall?” to writing your own Prebid bid adapter and hardening it for production — five progressive levels in one guide.
Multi-level track
This page is deliberately layered. Start at Level 0 if you have never heard of header bidding. Jump to Level 3 or 4 if you are already running Prebid in production and need adapter internals or operational depth. Every level builds on the one before it.
Jump to level
Before header bidding existed, publishers sold their remaining inventory through a “waterfall” — a sequential chain of ad networks each given a chance to fill the slot, one at a time. The problem: the first network in the chain always got the best crack at the inventory, even if a network further down would have paid more. Revenue was left on the table every single day.
WATERFALL (old way):
"I have one ticket to sell. I'll ask my friend first.
He offers $5. Sold. My other friend would have paid $20 — too bad."
HEADER BIDDING (new way):
"I have one ticket to sell. I'll text all 10 friends at once
and give them 90 seconds to reply with their best offer.
The highest reply wins. I get $20 instead of $5."
<head> tag so it ran before the page body loaded, giving buyers time to bid before the ad server was called. The name stuck even as implementations evolved.
Before writing any code, you need to understand the 8 moving parts and how they connect. Most explanations skip this and jump straight to configuration — which means practitioners configure things they cannot explain when something breaks.
| Term | What it is | Analogy |
|---|---|---|
| Header bidding wrapper | The JavaScript library (e.g. Prebid.js) that orchestrates the auction on the page | The auctioneer who calls all bidders simultaneously and collects offers |
| Bid adapter | A connector module inside the wrapper that speaks each SSP’s specific API language | A translator who converts the auctioneer’s request into each buyer’s native language |
| SSP (Supply-Side Platform) | The seller’s platform — AppNexus, Rubicon, Index Exchange, Magnite | The marketplace where buyers compete for the publisher’s inventory |
| Bid | A CPM price (in USD) returned by an SSP for a specific impression | The offer price a buyer submits at auction |
| Bid floor | The minimum CPM the publisher will accept — bids below this are rejected | Reserve price in an auction — any offer below this is declined |
| Key-value targeting | Data (e.g. hb_pb=3.50) the wrapper passes to the ad server so the right line item wins |
A secret code the auctioneer passes to the venue so they know which seat the winner gets |
| Ad server (GAM) | Google Ad Manager — the final decision-maker that selects which ad serves | The venue manager who decides whose reservation to honour based on who paid what |
| Bidder timeout | The maximum ms the wrapper waits for SSP bids before closing the auction | The auction closing time — bidders who reply late are excluded |
| Price granularity | How precisely bid prices are bucketed before being sent to GAM as key-values | Rounding: a $3.47 bid becomes “$3.40” or “$3.50” depending on bucket size |
| Win notice (nurl) | A URL the ad server pings back to the DSP to confirm the bid won and the clearing price | The auctioneer calling the winner to confirm their offer was accepted |
The browser loads HTML. The <script> tag for Prebid.js is in the page head or loaded async. GPT (Google Publisher Tag) also loads.
pbjs.que.push() defers setup until Prebid.js is ready. addAdUnits() registers which slots run header bidding and which adapters compete.
Prebid fans out to every adapter simultaneously. Each adapter converts the ad unit config into its SSP’s API format and fires an HTTP request.
Each SSP receives the request, runs its internal DSP auction (OpenRTB), and returns the highest bid it found — or no bid.
Adapters parse each SSP response and hand the CPM, creative, and metadata back to the Prebid auction manager.
whichever comes first. Late bids are dropped. Prebid selects the highest CPM bid per ad unit.
pbjs.setTargetingForGPTAsync() writes hb_pb, hb_bidder, hb_adid onto each GPT slot. These are the signals GAM uses to route to the right line item.
googletag.pubads().refresh() sends the ad request to GAM with the Prebid key-values attached. GAM now runs its own priority auction.
The Price Priority line item targeting hb_pb=3.50 competes with direct campaigns, Open Bidding, and house ads. Highest priority and best CPM wins.
If the header bidding line item wins, GAM returns a Prebid creative snippet. The snippet calls pbjs.renderAd(), which serves the winning DSP’s ad markup into the slot.
This is the standard integration used by most web publishers. It assumes you have a GAM account and a page that already loads GPT.
var adUnits = [
{
code : 'div-gpt-ad-leaderboard' , // must match GPT slot div ID exactly
mediaTypes : {
banner : { sizes : [[728 ,90 ], [970 ,90 ], [970 ,250 ]] }
},
bids : [
{ bidder : 'appnexus' , params : { placementId : 12345678 } },
{ bidder : 'rubicon' , params : { accountId : 9001 , siteId : 283 , zoneId : 1234 } },
{ bidder : 'ix' , params : { siteId : '47777' , size : [728 ,90 ] } },
{ bidder : 'pubmatic' , params : { publisherId : '158355' , adSlot : 'hb-leaderboard@728x90' } },
{ bidder : 'openx' , params : { unit : '540191387' , delDomain : 'pub.openx.net' } }
]
},
{
code : 'div-gpt-ad-mrec' ,
mediaTypes : {
banner : { sizes : [[300 ,250 ], [300 ,600 ]] }
},
bids : [
{ bidder : 'appnexus' , params : { placementId : 87654321 } },
{ bidder : 'rubicon' , params : { accountId : 9001 , siteId : 283 , zoneId : 5678 } }
]
},
{
code : 'div-gpt-ad-video' ,
mediaTypes : {
video : {
context : 'instream' ,
playerSize : [[640 , 480 ]],
mimes : ['video/mp4' ],
protocols : [2 , 3 , 5 , 6 , 7 , 8 ],
playbackmethod : [2 ],
skip : 1 ,
skipafter : 5
}
},
bids : [
{ bidder : 'appnexus' , params : { placementId : 11223344 , video : { skippable : true } } }
]
}
];
pbjs.setConfig({
bidderTimeout : 1500 ,
enableSendAllBids : false , // one winner per slot — cleaner GAM targeting
priceGranularity : {
buckets : [
{ max : 3 , increment : 0.05 }, // fine buckets where most bids land
{ max : 8 , increment : 0.10 },
{ max : 20 , increment : 0.25 },
{ max : 100 , increment : 1.00 }
]
},
userSync : {
syncEnabled : true ,
filterSettings : {
iframe : { bidders : ['appnexus' , 'rubicon' ], filter : 'include' },
image : { bidders : '*' , filter : 'include' }
},
syncsPerBidder : 5 ,
syncDelay : 3000
},
currency : { adServerCurrency : 'USD' },
schain : {
validation : 'strict' ,
config : {
ver : '1.0' , complete : 1 ,
nodes : [{ asi : 'your-exchange.com' , sid : 'pub-001' , hp : 1 }]
}
},
consentManagement : { // GDPR / TCF integration
gdpr : {
cmpApi : 'iab' , // reads from window.__tcfapi
timeout : 10000 , // ms to wait for CMP before continuing
allowAuctionWithoutConsent : false // do NOT run auction if no consent signal
},
usp : { cmpApi : 'iab' , timeout : 1000 } // CCPA
}
});
var googletag = googletag || {};
googletag.cmd = googletag.cmd || [];
var pbjs = pbjs || {};
pbjs.que = pbjs.que || [];
// Define GPT slots — these are the ad unit paths in your GAM network
googletag.cmd.push(function () {
googletag.defineSlot('/12345/homepage-leaderboard' , [[728 ,90 ],[970 ,90 ]], 'div-gpt-ad-leaderboard' )
.addService(googletag.pubads());
googletag.defineSlot('/12345/homepage-mrec' , [[300 ,250 ],[300 ,600 ]], 'div-gpt-ad-mrec' )
.addService(googletag.pubads());
googletag.pubads().disableInitialLoad(); // prevent GPT from loading ads before Prebid runs
googletag.pubads().enableSingleRequest();
googletag.enableServices();
});
// Prebid configuration and auction
pbjs.que.push(function () {
pbjs.setConfig({ /* ...setConfig as above... */ });
pbjs.addAdUnits(adUnits);
pbjs.requestBids({
timeout : 1500 ,
bidsBackHandler : function () {
pbjs.setTargetingForGPTAsync(); // write hb_pb, hb_bidder, hb_adid to GPT slots
googletag.cmd.push(function () {
googletag.pubads().refresh(); // fire GAM ad request with key-values attached
});
}
});
});
// === Useful console commands for QA ===
// pbjs.getBidResponses() — all bids per ad unit (bid, no-bid, timeout)
// pbjs.getHighestCpmBids() — winning bid per ad unit
// pbjs.getAdserverTargeting() — key-values written to each GPT slot
// pbjs.getAllWinningBids() — bids that won AND rendered
| Field | Value | Why |
|---|---|---|
| Line item type | Price Priority | Competes by CPM against Open Auction — the only type that lets header bidding win on price |
| Rate (CPM) | $3.50 (one line item per bucket) | Must match the exact hb_pb key-value bucket your granularity config produces |
| Key-value targeting | hb_pb equals 3.50 | This is how GAM knows to serve this line item when Prebid sets that key-value on the ad request |
| Creative type | Third-party — JavaScript snippet | Prebid standard creative calls pbjs.renderAd() to serve the actual DSP creative |
| Creative snippet | <script>pbjs.renderAd(document,'%%PATTERN:hb_adid%%')</script> | %%PATTERN:hb_adid%% macro is replaced by GAM with the actual bid ID at serve time |
| Sizes | All sizes you want to support | One creative per size or universal creative — wrong size causes blank slot |
| Priority | 12 | Competes by price at the same level as Open Auction and Open Bidding demand |
| End date | Far future (e.g. Dec 31, 2099) | Header bidding line items are evergreen — they should never expire |
Prebid.js runs in the browser. Prebid Server (PBS) moves the auction to a server. This is essential for CTV apps and mobile apps where there is no browser JavaScript runtime, and also beneficial for web when you want to reduce page-level auction latency by offloading adapter calls to the server.
A CTV app calls PBS at /openrtb2/auction with a standard OpenRTB 2.6 payload. PBS fans out to all configured SSP adapters on the server and returns a single aggregated response.
POST https://prebid-server.example.com/openrtb2/auction
Content-Type: application/json
{
"id" : "auction-ctv-001" ,
"tmax" : 1000 ,
"imp" : [
{
"id" : "1" ,
"video" : {
"mimes" : ["video/mp4" ],
"protocols" : [2 , 3 , 5 , 6 ],
"w" : 1920 , "h" : 1080 ,
"startdelay" : 0 , // 0 = pre-roll
"playbackmethod" : [2 ],
"skip" : 1 , "skipafter" : 5
},
"bidfloor" : 5.00 ,
"bidfloorcur" : "USD" ,
"ext" : {
"prebid" : {
"bidder" : { // per-bidder params — replaces Prebid.js adapter config
"appnexus" : { "placementId" : 11223344 },
"rubicon" : { "accountId" : 9001 , "siteId" : 283 , "zoneId" : 9999 },
"ix" : { "siteId" : "47777" }
},
"storedAuctionResponse" : "stored-resp-001" // optional: use cached test response for dev
}
}
}
],
"app" : {
"bundle" : "com.example.ctvapp" ,
"name" : "Example CTV" ,
"storeurl" : "https://channelstore.roku.com/details/123" ,
"publisher" : { "id" : "pub-456" }
},
"device" : {
"ua" : "Roku/DVP-9.10 (519.10E04142A)" ,
"devicetype" : 3 , // 3 = Connected TV / SmartTV
"ifa" : "aebe0a86-1f3a-4c65-b2bc-4f7b9c4e9d01" , // device advertising ID (RIDA on Roku)
"lmt" : 0 , // Limit Ad Tracking: 0 = not limited
"geo" : { "country" : "USA" , "region" : "CA" }
},
"user" : {
"ext" : {
"eids" : [
{
"source" : "liveramp.com" ,
"uids" : [{ "id" : "RampID-abc123" , "atype" : 3 }]
}
]
}
},
"regs" : { "coppa" : 0 , "ext" : { "gdpr" : 0 , "us_privacy" : "1YNN" } }
}
Instead of sending the full bidder config in every request, PBS supports stored requests: pre-configured templates stored in the PBS database. The app sends only an ID; PBS injects the full bidder params server-side.
{
"id" : "auction-ctv-002" ,
"imp" : [
{
"id" : "1" ,
"ext" : {
"prebid" : {
"storedrequest" : { "id" : "ctv-preroll-720" }
// PBS looks up "ctv-preroll-720" from its database and merges
// the stored bidder config, floor, and media type definition
}
}
}
],
"app" : { "bundle" : "com.example.ctvapp" },
"device" : { "devicetype" : 3 , "ifa" : "aebe0a86-..." }
// Bidder params, video sizes, floors etc. come from the stored request template
}
A Prebid.js adapter is a JavaScript module that translates between Prebid’s internal auction format and your SSP’s API. It must implement five functions. The adapter lives in modules/mybidderBidAdapter.js in the Prebid.js repo. Every adapter must pass Prebid’s automated test suite before it can be merged.
| Function | Called by Prebid when… | Must return |
|---|---|---|
| isBidRequestValid(bid) | Checking each ad unit before the auction | Boolean — true if params are valid, false skips this adapter for this unit |
| buildRequests(validBids, bidderRequest) | Building the HTTP request to your SSP | Object {method, url, data} or array of such objects |
| interpretResponse(response, request) | SSP responds with bids | Array of bid objects in Prebid’s internal format |
| getUserSyncs(options, responses, gdpr, usp) | After auction — cookie/user sync | Array of {type: 'iframe'|'image', url} sync descriptors |
| onBidWon(bid) | A bid from this adapter wins the GAM auction | Nothing — fire win notification side-effect here |
import { registerBidder } from '../src/adapters/bidderFactory.js' ;
import { BANNER, VIDEO } from '../src/mediaTypes.js' ;
import { deepAccess, logWarn } from '../src/utils.js' ;
const BIDDER_CODE = 'mybidder' ;
const ENDPOINT_URL = 'https://bid.mybidder.example.com/openrtb2/auction' ;
const SYNC_URL = 'https://sync.mybidder.example.com/usersync' ;
const SUPPORTED_MEDIA = [BANNER, VIDEO];
export const spec = {
code: BIDDER_CODE,
supportedMediaTypes: SUPPORTED_MEDIA,
// ── 1. Validate params before the auction ──────────────────────────────
isBidRequestValid : function (bid) {
if (!bid.params || !bid.params.placementId) {
logWarn(`[mybidder] missing required param: placementId on bid ` , bid);
return false ;
}
return true ;
},
// ── 2. Build the HTTP request to send to your SSP ─────────────────────
buildRequests : function (validBidRequests, bidderRequest) {
const gdpr = bidderRequest.gdprConsent;
const usp = bidderRequest.uspConsent;
const referer = deepAccess(bidderRequest, 'refererInfo.page' ) || '' ;
const openRtbRequest = {
id: bidderRequest.auctionId,
tmax: bidderRequest.timeout,
imp: validBidRequests.map(bid => {
const imp = {
id: bid.bidId, // Prebid-generated bid ID — must echo back in response
bidfloor: bid.params.floor || 0 ,
bidfloorcur: 'USD' ,
ext: { placementId: bid.params.placementId }
};
const bannerSizes = deepAccess(bid, 'mediaTypes.banner.sizes' );
if (bannerSizes) {
imp.banner = { format: bannerSizes.map(s => ({ w: s[0 ], h: s[1 ] })) };
}
const videoParams = deepAccess(bid, 'mediaTypes.video' );
if (videoParams) {
imp.video = {
mimes: videoParams.mimes || ['video/mp4' ],
protocols: videoParams.protocols || [2 , 3 , 5 , 6 ],
w: videoParams.playerSize?.[0 ]?.[0 ],
h: videoParams.playerSize?.[0 ]?.[1 ],
playbackmethod: videoParams.playbackmethod,
skip: videoParams.skip || 0 ,
skipafter: videoParams.skipafter || 5
};
}
return imp;
}),
site: {
page: referer,
domain: new URL(referer || 'https://unknown.com' ).hostname,
publisher: { id: validBidRequests[0 ]?.params?.publisherId || '' }
},
// Privacy signals — always include these
regs: {
coppa: 0 ,
ext: {
gdpr: gdpr?.gdprApplies ? 1 : 0 ,
us_privacy: usp || ''
}
},
user: {
ext: {
consent: gdpr?.consentString || '' ,
eids: validBidRequests[0 ]?.userIdAsEids || [] // Unified IDs from Prebid ID modules
}
}
};
return {
method: 'POST' ,
url: ENDPOINT_URL,
data: JSON.stringify(openRtbRequest),
options: { contentType: 'application/json' }
};
},
// ── 3. Parse the SSP response into Prebid bid objects ─────────────────
interpretResponse : function (serverResponse, request) {
const body = serverResponse?.body;
if (!body?.seatbid?.length) return [];
const bids = [];
body.seatbid.forEach(seatbid => {
(seatbid.bid || []).forEach(bid => {
const pbBid = {
requestId: bid.impid, // must match imp[].id from buildRequests
cpm: bid.price,
currency: body.cur || 'USD' ,
width: bid.w,
height: bid.h,
creativeId: bid.crid || bid.id,
ttl: 300 , // seconds this bid is valid — player must render within TTL
netRevenue: true , // true = price is net of SSP fee
meta: {
advertiserDomains: bid.adomain || [],
mediaType: bid.ext?.prebid?.type || BANNER
}
};
// Banner creative
if (bid.adm && !bid.ext?.prebid?.type?.includes('video' )) {
pbBid.ad = bid.adm;
}
// Video creative (VAST XML or URL)
if (bid.ext?.prebid?.type === 'video' ) {
pbBid.vastXml = bid.adm;
pbBid.vastUrl = bid.ext?.prebid?.targeting?.hb_uuid
? `https://prebid-cache.example.com/cache?uuid=` + bid.ext.prebid.targeting.hb_uuid
: undefined ;
pbBid.mediaType = VIDEO;
}
bids.push(pbBid);
});
});
return bids;
},
// ── 4. User sync — after auction, fire sync pixels ────────────────────
getUserSyncs : function (syncOptions, serverResponses, gdprConsent, uspConsent) {
if (!serverResponses?.length) return [];
const gdprParam = gdprConsent?.gdprApplies ? 1 : 0 ;
const consentParam = encodeURIComponent (gdprConsent?.consentString || '' );
const uspParam = encodeURIComponent (uspConsent || '' );
if (syncOptions.iframeEnabled) {
return [{
type: 'iframe' ,
url: `${SYNC_URL}?gdpr=${gdprParam}&consent=${consentParam}&us_privacy=${uspParam}&type=iframe`
}];
}
if (syncOptions.pixelEnabled) {
return [{
type: 'image' ,
url: `${SYNC_URL}?gdpr=${gdprParam}&consent=${consentParam}&type=pixel`
}];
}
return [];
},
// ── 5. Win notification — fires when this adapter's bid renders ────────
onBidWon : function (bid) {
if (bid.nurl) {
// Replace auction price macro with clearing price
const url = bid.nurl.replace('${AUCTION_PRICE}' , bid.cpm);
navigator.sendBeacon(url);
}
}
};
registerBidder(spec);
import { spec } from 'modules/mybidderBidAdapter.js' ;
import { newBidder } from 'src/adapters/bidderFactory.js' ;
const ENDPOINT = 'https://bid.mybidder.example.com/openrtb2/auction' ;
describe('mybidderBidAdapter' , function () {
const adapter = newBidder(spec);
describe('isBidRequestValid' , function () {
it('returns true for a bid with placementId' , function () {
const bid = { params: { placementId: 12345 } };
expect(spec.isBidRequestValid(bid)).to.equal(true );
});
it('returns false when placementId is missing' , function () {
const bid = { params: {} };
expect(spec.isBidRequestValid(bid)).to.equal(false );
});
});
describe('buildRequests' , function () {
const validBidRequests = [{
bidId: 'bid-abc-001' ,
params: { placementId: 12345 },
mediaTypes: { banner: { sizes: [[300 ,250 ]] } },
userIdAsEids: []
}];
const bidderRequest = {
auctionId: 'auct-001' ,
timeout: 1500 ,
refererInfo: { page: 'https://example.com/' },
gdprConsent: { gdprApplies: true , consentString: 'CPXx...' }
};
it('sends POST to the correct endpoint' , function () {
const request = spec.buildRequests(validBidRequests, bidderRequest);
expect(request.method).to.equal('POST' );
expect(request.url).to.equal(ENDPOINT);
});
it('includes GDPR consent in the request body' , function () {
const request = spec.buildRequests(validBidRequests, bidderRequest);
const body = JSON.parse(request.data);
expect(body.regs.ext.gdpr).to.equal(1 );
expect(body.user.ext.consent).to.equal('CPXx...' );
});
});
describe('interpretResponse' , function () {
it('returns an empty array for empty seatbid' , function () {
expect(spec.interpretResponse({ body: { seatbid: [] } }, {})).to.deep.equal([]);
});
it('correctly maps a banner bid response' , function () {
const resp = {
body: {
cur: 'USD' ,
seatbid: [{ bid: [{ impid: 'bid-abc-001' , price: 2.5 , w: 300 , h: 250 , adm: '<div>ad</div>' , crid: 'cr-1' }] }]
}
};
const bids = spec.interpretResponse(resp, {});
expect(bids[0 ].requestId).to.equal('bid-abc-001' );
expect(bids[0 ].cpm).to.equal(2.5 );
});
});
});
meta.advertiserDomains in the bid response. Run gulp test --file test/spec/modules/mybidderBidAdapter_spec.js before opening the PR.
Getting Prebid running is Level 2. Keeping it healthy at scale — catching revenue regressions before your yield team does, staying compliant with privacy law, and passing supply chain audits — is what separates practitioners from engineers.
| Metric | Healthy range | What degrades it | Alert threshold |
|---|---|---|---|
| Bid rate per adapter | 40%–80% of requests receive a bid | Floor too high, wrong params, geo targeting by DSP | Drop >15% from 7-day baseline |
| Timeout rate per adapter | <5% | DSP latency spike, bidderTimeout too low, network issues | >10% — escalate to adapter team |
| Win rate (header bidding vs GAM) | Depends on direct sell-through — track trend not absolute | Direct campaigns taking more inventory, floor changes | 20%+ week-on-week drop without a known direct campaign change |
| Revenue per 1000 auctions (RPM) | Site-specific baseline | Adapter going dark, price granularity regression, floor miscalibration | 5%+ drop vs same-day last week |
| User sync rate | >60% of users have at least one sync | CMP blocking iframe syncs, syncDelay too long, filterSettings too restrictive | <40% — diagnose CMP or filterSettings config |
# Format: SSP domain, publisher account ID, relationship type, TAG-ID
appnexus.com, 9001, DIRECT, f08c47fec0942fa0 # DIRECT = you have a direct deal with this SSP
rubiconproject.com, 158355, DIRECT, 0bfd66d529a55807
openx.com, 540191387, DIRECT, 6a698e965929d969
indexexchange.com, 185001, DIRECT
pubmatic.com, 158355, DIRECT, 5d62403b186f2ace
triplelift.com, 1001, RESELLER, 6c33edb13117fd86 # RESELLER = SSP sells your inventory on behalf of another
# Rule: every SSP in your Prebid adapter config must appear here
# Missing entries = DSPs with brand safety filters will not buy your inventory
Do not run header bidding for slots the user has not scrolled to yet. Use an Intersection Observer to trigger requestBids only when the slot enters the viewport.
▸ Reduces wasted auctions. Improves timeout budget for visible slots. Decreases page weight on initial load by deferring adapter JS execution.
CTV devices and older mobile hardware are slower. A 1500 ms timeout on desktop may need to be 2500 ms on a 2018 Roku device to capture the same percentage of bids.
▸ Detect device type from user agent or app metadata. Set bidderTimeout dynamically. Monitor timeout rate per device segment in your analytics.
Loading 10 adapter JS files on the page adds 100–400 KB. Route slow or less-critical adapters through Prebid Server via the s2sConfig option, keeping only fast adapters client-side.
▸ pbjs.setConfig({ s2sConfig: { accountId: '...', bidders: ['rubicon','openx'], endpoint: '...' } }) — adapters listed here run server-side, not in the browser.
Run two versions of your price granularity config on 50/50 split of users. Measure average clearing price and fill rate per variant over 7 days. Fine-grained buckets improve revenue but add GAM key-value complexity.
▸ Use a feature flag or A/B test framework to toggle between configs. Control group: medium (0.10 increment). Test group: custom fine-grained. Compare via GAM delivery report.
Static floors leave money on the table when demand is high and reduce fill when demand is low. Dynamic floor providers (PriceFloors module in Prebid) set floors per-impression based on historical CPM data.
▸ Enable floors module in Prebid. Configure a floor data URL returning JSON with floor rules per ad unit, geo, and device type. Monitor fill rate vs CPM tradeoff weekly.
Under GDPR, if your CMP does not grant Purpose 1 (storage and access) in the TCF consent string, most adapters will not send a user ID — dramatically lowering CPMs. CMP configuration is a yield management decision, not just a legal one.
▸ Test your consent string with the IAB TCF decoder. Confirm Purpose 1, 3, and 4 are granted for typical users in your consented population. Track CPM correlation with consent rate across markets.
| Tool | What it shows | How to use |
|---|---|---|
| pbjs.getBidResponses() | All bids, no-bids, timeouts per ad unit | Browser console on any Prebid page — first diagnostic step |
| pbjs.getHighestCpmBids() | Winning bid per slot with CPM and adapter name | Confirm which adapter is winning and at what price |
| pbjs.getAdserverTargeting() | Key-values written to each GPT slot | Compare hb_pb value against GAM line item targeting |
| pbjs.setConfig({debug: true}) | Full verbose auction log in browser console | Enable temporarily to see per-adapter rejection reasons |
| Prebid Analyst (Chrome extension) | Visual auction dashboard — bids, timeouts, key-values | Install from Chrome Web Store — no code needed |
| GPT console (googletag.openConsole()) | GAM line item evaluation, targeting matches, creative selected | Run in browser console — Targeting tab shows all key-values GAM received |
| Browser Network tab (HAR export) | Actual HTTP requests to each SSP endpoint and response times | Filter by adapter endpoint domain — diagnose latency per SSP |
| PBS /openrtb2/auction?debug=true | Full debug response from Prebid Server including per-adapter logs | Add ?debug=true to PBS requests in dev — never in production |
Across all levels — the failures that surface most often in production, with diagnostic steps.
Every adapter returns empty. The auction fires but GAM receives no Prebid key-values. Likely: Prebid.js failed to load, addAdUnits was not called, or a JS error broke the entire auction queue.
▸ Check console for JS errors. Confirm pbjs.adUnits is populated. Enable debug mode. Verify the Prebid.js script tag is loading — use Network tab to confirm 200 response.
Key-values are set on GPT slots but the Price Priority line item never wins. Usually: hb_pb bucket value in the key-value does not match the CPM in the GAM line item targeting.
▸ Run googletag.openConsole() → Targeting. Compare the exact hb_pb string (e.g. “3.50” not “3.5”) against GAM line item key-value rule. Decimal format and leading zeros must match exactly.
One SSP consistently submits the highest bid, even suspiciously so. Other adapters are effectively excluded from revenue even though they have active demand. Could be a DSP bidding strategy or a floor set below that SSP’s minimum.
▸ Pull bid distribution per adapter from your analytics. If one adapter is winning 90%+ of auctions, check whether it is CPC or CPM-bidding, and whether it has a preferential floor. Run an experiment with that adapter removed to measure true competitive pressure.
PBS is being called but returning empty seatbid. Likely: device type is not set to 3 (SmartTV) or 4 (SetTopBox) in the request, so CTV-specific DSPs exclude the impression. Or: the app bundle is not in the publisher’s ads.txt equivalent (app-ads.txt).
▸ Confirm device.devicetype is 3 or 4 in the PBS request. Check that your app bundle (app.bundle) is listed in your app-ads.txt file hosted at your developer domain. Verify floors are not above CTV CPM expectations for your genre/daypart.
CPMs dropped 20%+ on a Tuesday with no code change. A connected SSP or DSP changed their adapter behaviour, pulled demand from certain geos, or a privacy regulation change went live.
▸ Compare bid rate and CPM per adapter before vs after the drop date. Isolate which adapter(s) changed. Check if the date coincides with a regulatory event (e.g. CPRA enforcement) or an SSP product announcement. Contact the adapter team with specific date-range data.
The Prebid.js adapter passed review and was merged. A publisher integrates it. All other adapters bid fine but the new adapter returns 0 bids. The adapter was built with test/mock endpoint URLs that only worked in the PR test environment.
▸ Confirm the endpoint URL in the adapter points to production, not a dev/staging server. Check that the publisher’s placementId or account ID was properly provisioned on the SSP side. Use the HAR trace to confirm the HTTP request reaches the SSP and inspect the raw response body.
Level 0
Level 1
window.pbjs in the console).pbjs.getBidResponses() and list every adapter: bid amount, status, latency.pbjs.getAdserverTargeting() and note the hb_pb and hb_bidder values.live-auction-analysis.md — adapters found, bid distribution, fastest/slowest adapter.Level 2
hb-test-page.html + auction-log.json.Level 3
pbs-request.json + pbs-response-annotated.json.Level 4
modules/mybidderBidAdapter.js using the code above as a starting point.json-server or similar to return a static OpenRTB response).gulp test --file test/spec/modules/mybidderBidAdapter_spec.js and get all tests passing.Level 5
hb-ops-report.md — revenue trend, adapter health, compliance status, and recommended actions.Enter any two values
to calculate the third
More tools coming soon