Blog · Protocol

nurl vs burl vs lurl lines up a win notice, a billing notice, and a loss notice.

People search nurl vs burl, and lurl vs nurl, because all three are URLs on the same Bid. They are not three spellings of one pixel. OpenRTB 2.5 split the win from the bill, and added a loss notice, because a stack that treated the win URL as the invoice was already wrong.

Three calls, three jobs

nurl is the win notice. The exchange calls it if the bid wins. Winning is the auction result. It is not proof the ad was delivered, viewed, or billable. The response body of that call can carry the markup, and only the markup, when you did not put the creative in adm.

burl is the billing notice. The exchange calls it when, under that exchange's policy, the win becomes something it will pay or charge for. That moment is often delivery or a view. It is not the moment the auction cleared. Best practice in the spec is to fire burl from the exchange server, close to the books, rather than from the device.

lurl is the loss notice. The exchange calls it when it knows the bid lost. The useful token on that URL is ${AUCTION_LOSS}, an integer from the loss reason list. A loss URL with no loss macro tells you that you lost and nothing else. Some exchanges never call lurl. Some call it and blank the price.

FieldWhen it firesWhat a bidder should book
nurlThe bid wins the auctionA win. Not a delivered impression, and not a bill, unless you still account on it.
burlThe exchange decides the win is billableThe money. Policy is the exchange's: delivery, view, or another event it defined.
lurlThe bid is known to have lostThe reason, if ${AUCTION_LOSS} is on the URL. Not a spend event.

Billing on nurl double-counts the day you also honor burl

Before burl existed, a lot of bidders incremented spend inside the nurl handler, because the win notice was the only callback they had. That handler is still in production. OpenRTB 2.5 did not delete it. Exchanges that implemented billing notices now call burl as well, and they may still call nurl for the win.

If both handlers add the clearing price to spend, one impression is two invoices. The exchange's own books used burl. The bidder's books used both. The discrepancy ticket looks like an exchange underpay. The log shows two HTTP 200s, one to each path, with the same ${AUCTION_IMP_ID}.

The split to ship is dull and strict. nurl records that this bid id won this impression id, and it can fetch markup if you still serve that way. burl is the only handler that adds money. lurl never adds money. A retry of any of them has to be idempotent on the impression id, because exchanges retry notices.

A browser-called nurl is a pixel, and the price is in the page

Nothing in the protocol forces the exchange to call nurl from its own servers. Plenty of stacks drop the win URL into the creative as an image, or rely on the device to request it. An ad blocker, a navigated-away page, or a TV that never loads the pixel then looks like the impression did not happen. The auction already awarded it. If you were still billing on nurl, the publisher is unpaid and the bidder thinks it lost a win it actually had.

The same browser call substitutes ${AUCTION_PRICE} before the device requests the URL. The clearing price is then in a query string on the page. That is why exchanges encrypt the price when it has to leave the server, using a suffix the two parties agreed on, such as ${AUCTION_PRICE:B64}. The algorithm is not standardized. A bidder that reads the raw macro on a client-side notice is either seeing a leaked price or a ciphertext it does not know how to open.

burl exists so the bill does not depend on that hop. Server-side, the exchange can put ${AUCTION_PRICE} on the URL without putting it in the browser. A bidder that only looks at client logs will swear burl never fired. It fired on the exchange, toward the bidder's notice host, and the creative never saw it.

adm wins when both the bid and the notice carry markup

OpenRTB allows the creative in adm, or in the body of the win notice, not as two competing copies. If both are present, adm is what gets served. The nurl response is ignored for markup. A bidder that puts the real creative only in the win-notice body, and also sends a stub in adm, serves the stub.

Markup served on the win notice has a second failure the adm path does not. The exchange has to call nurl and receive a body that is the ad and nothing else before it can render. An HTTP failure after you already won forfeits the impression. Putting the creative in adm removes that round trip. The win notice can still be called for accounting. It should not be the only copy of the ad.

{
  "id": "bid-0001",
  "impid": "1",
  "price": 9.43,
  "adm": "<VAST version=\"3.0\">...</VAST>",
  "nurl": "https://bidder.example/win?imp=${AUCTION_IMP_ID}",
  "burl": "https://bidder.example/bill?imp=${AUCTION_IMP_ID}&p=${AUCTION_PRICE}&cur=${AUCTION_CURRENCY}",
  "lurl": "https://bidder.example/loss?imp=${AUCTION_IMP_ID}&loss=${AUCTION_LOSS}&mtw=${AUCTION_MIN_TO_WIN}"
}

That nurl records the win and does not carry the price. The burl carries the price and the currency. The lurl carries the loss code and, when the exchange will say it, the price that would have tied. The creative is in adm, so a failed win-notice fetch does not blank the ad.

A notice without the macro it exists for cannot settle

Section 4.4 of OpenRTB 2.6 names the substitution tokens. ${AUCTION_PRICE} is the clearing price. ${AUCTION_CURRENCY} is the currency of that price. ${AUCTION_LOSS} is the loss reason. ${AUCTION_MIN_TO_WIN}, added in 2.6, is the minimum price that would have tied the winner when you lost, or the next bid when you won. The exchange replaces a known macro as text. An optional value it does not have becomes an empty string. Values are not URL-encoded for you.

A burl or nurl with no price macro cannot tell the bidder what cleared. The handler may still return 200. Spend stays at the bid price, or at zero, depending on which bug you wrote. A lurl with no ${AUCTION_LOSS} cannot train a shading model. ${AUCTION_MIN_TO_WIN} is allowed to be empty when the exchange will not share price, or when price was not why you lost. Empty is a policy. A missing macro name is a template that never asked.

Typos survive until the notice 404s or the price parser throws. ${AUCTION_PRCIE} is not on the list, so the exchange leaves it in the URL or strips it as unknown, and your handler looks up a column that is the literal string. %%WINNING_PRICE%% is a different exchange's macro. OpenRTB substitution does not expand it. A bid that wins on an OpenRTB exchange with only the percent-encoded Google token bills nothing, and the same creative on an exchange that speaks that token bills normally. The macro alphabet is part of the comparison.

Loss codes are not no-bid codes

${AUCTION_LOSS} is an integer the exchange chose after it saw your bid. The list lives with the loss reason codes: 100 means the bid was below the auction floor, 102 means you lost to a higher bid, and the 200 range is creative filtering. nbr on a bid response is the opposite direction. The bidder sends it when it chooses not to bid. A 102 on lurl is not an nbr, and an HTTP 204 no-bid never produces a loss notice for a bid you did not send.

Exchanges may withhold the clearing price on lurl, which means ${AUCTION_PRICE} becomes empty even though the macro is present. Shading that treats empty as zero will think the opponent paid nothing. Read empty as undisclosed. Read 102 plus ${AUCTION_MIN_TO_WIN} as the price that would have tied, when the exchange actually substituted one.

A loss notice can also arrive late, after you already counted a provisional win from nurl. If a downstream filter rejects the creative, you can win and then lose. Booking spend on nurl and never reversing it on a later lurl leaves the impression in the win log and in the loss log. The impression id is the join. The URL that fired last is not the one that decides the money. burl is.

Loss code 0 arrived on lurl, and the bid won

The loss list starts at 0, Bid Won. That code exists so one notice pipeline can report every outcome, including the bids that did not lose. An exchange that calls lurl for every bid will send ${AUCTION_LOSS} = 0 for the winner. A handler that treats every loss URL as a loss will reverse a win, or open a loss row for an impression that also received burl.

Read the integer before you touch spend. 0 is a win reported on the loss channel. 100 is below the auction floor, 101 is below the deal floor, 102 is a higher bid, 103 is a deal that took priority over an open-auction bid. The 200 range is the creative, after the auction: pending scan, disapproved, size, insecure markup. Those are not economic losses, and shading them as if someone paid more trains the model on a filter. Codes at 500 and above are private to that exchange. A parser that maps unknown codes to 102 will call a publisher block a price loss.

Code 4, invalid deal id, is the deal you sent that the exchange does not know. Code 7 is missing markup. Code 8 is a missing crid the exchange required. None of those are fixed by raising the bid. A loss dashboard that only counts 102 hides the notices that are telling you the bid object itself was unusable.

The id on the notice is not bid.id

${AUCTION_ID} is BidRequest.id. ${AUCTION_BID_ID} is BidResponse.bidid, the optional response-level id, not bid.id and not bid.adid. ${AUCTION_IMP_ID} is imp.id from the request. ${AUCTION_AD_ID} is bid.adid, the markup id, which is also not crid. ${AUCTION_SEAT_ID} is the seat the bid was made for.

An omitted optional value becomes an empty string. If you never set bidid, ${AUCTION_BID_ID} arrives empty, and a join from the notice back to the bid row on that column matches every empty bid, or matches nothing. The id you generated on the bid object has no macro. Put the request id, the impression id, and your own bid id in the query yourself if you need them, and use ${AUCTION_ID} and ${AUCTION_IMP_ID} as the exchange's confirmation of those two. Do not store ${AUCTION_AD_ID} in the creative-id column.

${AUCTION_IMP_TS} is a Unix timestamp in milliseconds of when the impression was fulfilled, not when the auction ran. A billing notice that carries it is telling you fulfillment time. Comparing that number to the auction clock, or treating it as seconds because event_time on a pixel is seconds, files the bill on the wrong day or in 1970.

The clearing price is after the discount, and it is not your bid

${AUCTION_PRICE} is the clearing price in the same currency and units as the bid, CPM, and it is the final price after the seller discount. ${AUCTION_MBR}, since 2.5, is that clearing price divided by the bid price. ${AUCTION_DISCOUNT_PCT} and ${AUCTION_DISCOUNT_CPM} describe the discount itself. If you book ${AUCTION_PRICE} and then subtract ${AUCTION_DISCOUNT_CPM}, you removed the discount twice.

Booking bid.price instead of the macro is only close when the auction is first price (at = 1) and there is no discount. Second price clears at someone else's bid. A discount moves the clear even in first price. ${AUCTION_MULTIPLIER} is the quantity of impressions won on multi-viewer inventory, a float that may be below 1. Multiplying the clear by that number as if it were a headcount overstates a screen that the exchange already scored under 1, and ignoring it understates a screen scored above 1.

Currency is ${AUCTION_CURRENCY}, and the macro page calls it confirmation, not a conversion. A handler that assumes USD because the bid was in USD will mis-file a notice whose currency macro says otherwise. Empty price on lurl is still undisclosed. Empty price on burl is a notice that cannot settle, which is a different bug from a loss notice that is allowed to hide the opponent's price.

The macro inside adm is a second bill

Substitution is not limited to the three notice URLs. The exchange replaces the same tokens inside adm before the markup is served. A VAST Impression URL, or a display pixel, that contains ${AUCTION_PRICE} fires from the device when the ad renders. If the spend handler on that pixel adds the clear, and the burl handler adds it too, you double-counted without ever touching nurl.

The device pixel also puts the price back in the browser, which is the leak burl was meant to avoid. Encrypt it there, with a suffix that exchange actually implements, or leave the price off the creative and keep it on the server-side billing notice. A pixel whose only job is to count a render should not carry ${AUCTION_PRICE} at all.

The win-notice body has the opposite constraint. When markup is served on nurl, that response has to be the ad and nothing else. A handler that returns a JSON receipt, or a 1x1 gif, because the same route also records wins, serves the receipt as the creative. Split the route. Accounting is query parameters and a 200. Markup is adm, or a dedicated body that contains only the ad.

Two bids for one impression collide on imp.id

A seat may send more than one Bid for the same impid. Each bid has its own nurl, burl, and lurl. Idempotency on ${AUCTION_IMP_ID} alone treats the second notice as a retry of the first and drops it. One of the two bids never settles, or the later price overwrites the earlier one.

bid.id has no macro. The exchange will not echo it unless you placed it in the URL yourself. ${AUCTION_BID_ID} will not save you: that token is one bidid for the whole response, shared by every bid in it. The settlement key is the impression id plus the bid id you wrote into the query. Retries of the same notice still match that pair. A different bid for the same impression does not.

bid.exp and imp.exp are seconds between the auction and the impression, not tmax. tmax is the bid deadline in milliseconds. Loss code 2 means the impression opportunity expired before the bid could be used. A nurl that already fired, followed by lurl with code 2, is a win that never became billable. If burl never arrives, code 2 is the reversal, not a second mystery loss. Booking the win notice and waiting forever for a billing notice leaves that impression in spend after the window closed.

The clearing price is a CPM, so one impression is not that number

bid.price and ${AUCTION_PRICE} are CPM, cost per thousand, even though the notice is for a single impression. The money for that one impression is the macro divided by 1000, in ${AUCTION_CURRENCY}. A handler that writes the macro straight into the invoice is a thousand times high. The win still looks plausible, because 9.43 feels like a small number, and the books are nine thousand dollars instead of nine.

The spec tells you to do that division in integer or decimal arithmetic, not in a binary float. A CPM of 1.10 divided by 1000 in a float becomes a repeating fraction, and a day of those fractions does not match the exchange's statement. Store the CPM as they sent it, divide once when you post the ledger, and keep the currency macro beside it. ${AUCTION_CURRENCY} is a confirmation of the currency the clear is already in. It does not convert. If it disagrees with BidResponse.cur, you have two currencies for one notice and you do not get to pick the one that makes the day balance.

seatbid.group defaults to 0, which means each impression stands alone. Set to 1, the seat only wants the impressions if it can win all of them. A nurl for one impid in that seat is not a bill yet. If another impression in the group loses, the package fails, and a later lurl on the one you thought you won is the cancellation. Booking each win notice as it arrives, then ignoring the group flag, spends on a package the exchange did not award.

mtype picks the slot, and the wrong one is a loss you cannot outbid

mtype, added in 2.6, says which impression subtype this bid is for: 1 banner, 2 video, 3 audio, 4 native. The exchange uses it to tie the bid to the right object inside imp. Loss code 204 is incorrect creative format, the case of video markup in a banner slot or the reverse. Code 203 is the size. Neither one moves if you raise price.

A bidder that omits mtype on a 2.6 exchange leaves the exchange to guess from adm. A VAST document in a bid that also claims mtype 1 is a contradiction, and the loss you get is the format code, not 102. Read 204 and 203 before you retune the bid. The creative did not lose an auction. It did not fit the impression the request offered.

OpenRTB 2.3.1 existed in part because ${AUCTION_BID_ID} was being substituted from the wrong field. The correction is BidResponse.bidid. An older notice stack that still writes bid.id into that macro, or reads the macro back into bid.id, joins the settlement to a different row than the one 2.3.1 named. The field list on the current spec is the one to store. The macro name did not change when the source field was fixed.

AUDIT is the price during a scan, and the scan can call burl

When markup is rendered for testing or ad-quality review, the best practice in the spec is to replace macros with the literal string AUDIT. That is not a number. A settlement handler that parses ${AUCTION_PRICE} as a float will throw, or it will store zero, every time a scanner loads the creative. The auction never happened. The books still moved if the handler's failure path inserts a row.

Scanners also request the URLs in the markup. An Impression pixel, or a burl the exchange calls because the scan counted as a render, arrives at the same host as a real bill. If the only check is that the path is the billing path, the scan is spend. Reject AUDIT before you divide by 1000. Reject a notice whose auction id is not one you sent. A creative-approval tool is not an exchange.

Substitution is textual and does not care if the result is still valid XML or a valid URL. An unknown optional value becomes an empty string, so an attribute that held the price macro can become an empty price, or the literal AUDIT when a scanner rendered it. The player then fails the ad, the nurl already fired, and you have a win with no render and no burl. Put macros in query positions where the substituted value is a number, an id, or an ISO currency code. Do not put them inside markup you also escape, and do not put them where an empty string changes the document.

test=1 is an auction that must not become spend

test on the bid request defaults to 0. Set to 1, the auction is non-billable test traffic. The exchange may still call nurl, burl, and lurl, because the protocol machinery is the same. A handler that books every burl will book the test. The flag was on the request, which your notice handler does not have unless you stored it against ${AUCTION_ID}.

This is not the AUDIT string. AUDIT is what a scanner puts in place of a macro when nobody ran an auction. test = 1 is a real auction the exchange has already said not to invoice. Drop it before the divide-by-1000 step. A test win that lands in production spend looks like a reconciliation gap against an exchange that correctly omitted it.

Deal.at can override the request auction type for one deal. 1 is first price, 2 is second price, and 3 means the value in that deal's bidfloor is the agreed price, not a minimum. On an at=3 deal, ${AUCTION_PRICE} should be that floor. Booking bid.price bills whatever the bidder offered above or below the agreement. The deal's bidfloorcur does not inherit the impression's bidfloorcur. A euro floor on the impression and a bare floor on the deal means the agreed price is in USD.

The loss code names a field on the request, not a weak price

Loss 6 is an advertiser domain that was missing, malformed, or not allowed. The bid needed adomain. Loss 205 is that domain against badv. Loss 209 is the creative category against bcat. Loss 206 is an app id against bapp. Loss 210 is a creative attribute against battr, such as autoplay or expandable, on the banner, video, audio, or native object. None of these move if the CPM goes up.

Those lists are on the request that ran. A different request for the same placement can carry a different bcat, or none. Only one of acat and bcat should be present. Category codes are interpreted under cattax. The same integer in two taxonomies is two categories. A loss 209 without the cattax from that request is a code you cannot map.

wseat and wadomain on a deal restrict which seats and which advertiser domains may bid it. Omission means no restriction. pmp.private_auction = 1 means the impression is only the listed deals. An open-auction bid on that impression loses to the deal structure, which is loss 103 when a deal bid took it, not 102. Read the request before you read the loss as a price.

What to check on one bid before you compare exchanges

  • nurl records the win. It does not add spend if burl is also in the bid.
  • burl is server-side and contains ${AUCTION_PRICE} and ${AUCTION_CURRENCY}.
  • lurl contains ${AUCTION_LOSS}. Treat an empty ${AUCTION_PRICE} on that URL as undisclosed.
  • The creative is in adm, or only in the win-notice body, not split across both.
  • Macros are ${AUCTION_*} names from section 4.4. A %%WINNING_PRICE%% token is a different exchange.
  • Notice handlers are idempotent on ${AUCTION_IMP_ID}.
  • ${AUCTION_LOSS} of 0 is a win. Do not reverse spend for it.
  • Join on ${AUCTION_ID} and ${AUCTION_IMP_ID}. ${AUCTION_BID_ID} is bidid, and it is empty when you did not set one.
  • Book ${AUCTION_PRICE} once. Do not also subtract ${AUCTION_DISCOUNT_CPM}. Do not also add it from a pixel inside adm.
  • Put bid.id in the notice query. Idempotency on ${AUCTION_IMP_ID} alone collapses two bids for one impression.
  • Divide ${AUCTION_PRICE} by 1000 for one impression. It is CPM. Keep ${AUCTION_CURRENCY} beside it.
  • If seatbid.group is 1, do not book a partial set of win notices. The seat asked for every impression in the group.
  • A lurl with code 2 after a nurl expires the win. Do not leave that impression in spend when burl never came.
  • Loss 203 and 204 are size and format. Check mtype against the imp subtype before you raise the price.
  • If the price string is AUDIT, it is a scan, not a clear. Do not divide it, and do not book it.
  • If the request had test = 1, the notices are non-billable. Store that flag against ${AUCTION_ID}.
  • On a deal with at = 3, the bill is the deal floor, in the deal's currency, not bid.price.
  • Loss 6, 205, 206, 209, and 210 are domain, category, app, and attribute blocks. Raising the price does not clear them.

The field list and the markup precedence are on the bid response page. The fourteen macros and the encoding suffix are on the macro table. Loss integers are on the loss reason codes. RTBlint warns when a billing notice has no price macro (openrtb.bid.price_macro_missing), when a loss notice has no loss macro (openrtb.bid.loss_macro_missing), and when a token is not in the 2.6 list (openrtb.macro.unknown). Those checks are the template. They do not prove the exchange called the URL, and they do not prove your handler booked the right one.

Further reading