Skip to content
Charles Shao
Go back

OpenRTB 协议精讲:bid request / response 对象模型、拍卖结算与 win / loss / billing notice

Updated:
views

本文属于 Bidding 竞价引擎链路 系列。

总览篇把 Bidder 拆成「解析 → 定向 → 召回 → 打分 → 出价 → 排序 → 填充」七级漏斗,第一级解析(Parse)要把「原始竞价请求」变成内部可算对象——而那份「原始竞价请求」的格式,正是本篇的主角 OpenRTB。本篇不碰引擎实现,专讲 Bidder 每天要吞吐的这份协议本身:它长什么样、字段是什么语义、赢了怎么结算与回传。读完再回到解析层,你就知道它在归一化的到底是什么。

OpenRTB 不只是竞价引擎的「母语」,也是整个程序化生态的通用语言——生态全景里画的角色、交易模式分出的 RTB / PMP / PD / PG、last look 里的拍卖博弈、供应链 schain 的核验,全都踩在这份协议之上。只是它太过基础,反而常被当成「背景板」一带而过。而它约定的核心其实只有一句话:DSP 与 SSP / ADX 之间,靠这份 JSON 协议对话。

这篇文章就把这根「线」本身拆开——不讲角色、不讲商业模式,只讲协议:一次曝光机会被编码成什么样的 bid request、DSP 回一个什么样的 bid response、交易所拿到出价后怎么结算、赢家 / 输家分别收到什么通知(notice)、成交价怎么通过宏回填、以及这套 JSON 对象模型这些年是怎么演进的。读完之后,别处一带而过的 imppmp.deals[]source.schainnurl${AUCTION_PRICE},你都能指着字段说出它在协议里的确切位置和语义。

范围划界:本文讲协议本身(对象模型 + 结算语义)。竞价引擎怎么解析和归一化这份请求,是下一篇解析层(Parse)的事;库存的几种卖法(RTB/PMP/PD/PG)见交易模式source.schain供应链核验schain 专文。这里只在字段层面点到它们,不重复展开。

OpenRTB 对象模型总览:左侧蓝色大面板 BidRequest(顶层字段 id / at / tmax / cur / test),向下挂出核心子对象——imp[](每个广告位,内含 banner / video / native / audio 四选一、bidfloor、pmp)、site 或 app(二选一,描述媒体环境)、device(ua / ip / ifa / geo / lmt)、user(id / buyeruid / consent)、source(fd / tid / schain)、regs(coppa / gdpr / gpp);右侧绿色大面板 BidResponse(顶层 id / cur / nbr),向下挂出 seatbid[](按买方席位分组)→ bid[](price / impid / adm / nurl / burl / lurl / adomain / crid / dealid);中间一条粗箭头从 BidRequest 指向 BidResponse,标注「交易所 → DSP:~100ms 内一来一回」;底部旁注:请求描述「这是一次什么样的曝光机会」,响应描述「我出多少钱、投什么创意、赢了怎么通知我」 一句话读图:请求描述机会,响应描述出价。左边 BidRequest 的每个子对象都在回答「关于这次曝光你需要知道的一件事」(在哪个位、什么设备、哪个人、经过谁、受什么法规约束);右边 BidResponse 则把「出多少、投什么、赢了怎么通知」打包进 seatbid[].bid[]。后面各节就沿着这棵树逐个拆。

TL;DR

Table of contents

Open Table of contents

1. 为什么需要一份协议:从「两两私约」到 OpenRTB

回到 last look 那篇提过的年代——RTB 刚起步时,每家 DSP 要接每家交易所,都得私下约定请求长什么样、字段叫什么、价格单位是什么。买卖双方数量一多,这就是个 (N \times M) 的对接地狱:接一个新交易所等于重写一遍解析。

OpenRTB 做的事,本质和 HTTP、SQL 一样——立一份大家都认的标准接口,把 (N \times M) 压成 (N + M):DSP 只实现一次 OpenRTB,就能接所有遵循它的交易所;交易所同理。IAB(后来是 IAB Tech Lab)从 2010 年起维护这份规范,RTB 生态能长到今天这个规模,这份协议是地基。

它约定的其实只有两样东西:

外加一套结算与通知的语义(谁赢、成交多少、怎么回传),以及一个扩展机制ext)留给各家塞私货。就这么点东西,撑起了每天数以万亿计的竞价。

一个必须先建立的直觉:OpenRTB 是「交易所主持、DSP 应答」的模型。交易所把同一个 bid request 广播给多个 DSP,收齐 bid response自己跑拍卖、定胜负与成交价。DSP 永远只看得到自己的那一份请求和自己的出价——这也是 last look 那类信息不对称之所以可能存在的协议前提。

2. BidRequest:把一次曝光机会编码成 JSON

先看一个精简但字段齐全的真实形态(省略号处为省略字段):

{
  "id": "1a2b3c-req-0001",
  "at": 1,
  "tmax": 120,
  "cur": ["USD"],
  "imp": [
    {
      "id": "1",
      "tagid": "homepage-top",
      "bidfloor": 0.85,
      "bidfloorcur": "USD",
      "secure": 1,
      "banner": { "w": 300, "h": 250, "pos": 1 },
      "pmp": {
        "private_auction": 0,
        "deals": [
          { "id": "DEAL-42", "bidfloor": 2.5, "at": 1, "wseat": ["agencyX"] }
        ]
      }
    }
  ],
  "app": {
    "bundle": "com.example.news",
    "publisher": { "id": "pub-9" },
    "cat": ["IAB12"]
  },
  "device": {
    "ua": "Mozilla/5.0 ...",
    "ip": "203.0.113.7",
    "ifa": "6D92078A-8246-...",
    "lmt": 0,
    "os": "iOS",
    "devicetype": 4,
    "geo": { "country": "USA", "type": 2 }
  },
  "user": { "id": "ssp-side-user-77", "buyeruid": "dsp-side-user-abc" },
  "source": { "fd": 1, "tid": "txn-555", "schain": { "...": "见 schain 专文" } },
  "regs": { "coppa": 0, "ext": { "gdpr": 0, "gpp": "DBABzw~..." } }
}

这份 JSON 的顶层字段和子对象,可以按「它在回答关于这次曝光的什么问题」归类:

对象 / 字段回答的问题关键子字段(挑重点)
id这是哪次请求交易所生成的唯一 ID,响应里要原样带回
imp[]要卖的是哪些广告位见 §2.1,请求的绝对核心
at用哪种拍卖结算1 一价 / 2 二价+;见 §4
tmax最多给你多少毫秒作答交易所给 DSP 的时间预算,超时的响应直接丢
cur[]用什么货币出价允许的货币列表,默认 USD
site / app曝光发生在哪二选一:网页环境用 site、App 用 app;含 domain/bundlepublishercat 品类
device什么设备 / 网络 / 位置uaip/ipv6ifa 广告标识、lmt/dnt 限跟踪、geoconnectiontype
user面对的是哪个人id(SSP 侧)、buyeruid(DSP 侧,见 §2.3)、data[] 人群标签
source这条请求经过了谁fd 谁做最终决策、tid 事务 ID、schain 供应链(见 schain 专文)
regs受什么隐私法规约束coppa、以及 ext 里的 gdpr/consent、gpp/gpp_sid
test是不是测试流量1 表示测试,别真扣钱
bcat/badv/bapp媒体不接受什么屏蔽的品类 / 广告主域名 / 竞品 App
wseat/bseat谁能 / 不能参与白 / 黑名单席位
ext协议没覆盖的各交易所私有字段的容器(见 §7)

下面拆三个最该讲清楚的:impsite vs app、以及 user 里那对容易搞混的 ID。

2.1 imp:请求的核心,一个广告位一个

bid request 的一切都是为 imp 服务的——它是真正要卖的东西。一次请求可以带多个 imp(一个页面上有多个广告位),但更常见是一个。一个 imp 里最关键的是广告形态四选一

外加两个几乎每次都在看的字段:

别把 imp.id(本次请求内广告位的局部编号,响应里 bid.impid 要对上)和 bid request.id(整条请求的全局 ID)搞混——这是新手解析时最常见的一类串号 bug。

2.2 site vs app:二选一,别同时出现

同一次曝光只可能发生在一个环境里,所以 site(网页)和 app(移动 / CTV 应用)互斥,规范要求只出现其一。它们结构相似,都挂着 publishercontentcat(IAB 品类)、keywords,区别在身份字段:

对买方,site vs app 直接决定用哪套定向与归因逻辑(Web 靠 cookie / 广告主 Ad Server,App 靠设备 ID / MMP),所以解析时第一件事往往就是分流这两种环境。

2.3 user.id vs user.buyeruid:cookie matching 的产物

user 对象里有两个 ID,是新人最容易糊的一对:

为什么要两个?因为买卖双方各自维护自己的用户体系,同一个人在 SSP 眼里是 A、在 DSP 眼里是 BCookie Matching(cookie 同步) 就是双方事先通过 pixel 互换、建立起 A↔B 映射表;交易所发请求时,顺手把该 DSP 认得的 buyeruid 填进去,DSP 拿到就能查自己的画像 / 频控 / 归因。

第三方 cookie 退场后,这套 buyeruid 匹配率坍塌,正是 RTA(不共享 ID、只回传决策)和以第一方数据为核心的方案兴起的直接背景——协议字段还在,但它承载的确定性在消失。

3. BidResponse:把一次出价编码成 JSON

DSP 在 tmax 内决策完,回一份 bid response

{
  "id": "1a2b3c-req-0001",
  "cur": "USD",
  "seatbid": [
    {
      "seat": "agencyX",
      "bid": [
        {
          "id": "bid-001",
          "impid": "1",
          "price": 3.20,
          "adm": "<div>...creative markup...</div>",
          "adomain": ["example-brand.com"],
          "crid": "creative-88",
          "cid": "campaign-12",
          "cat": ["IAB19"],
          "w": 300,
          "h": 250,
          "dealid": "DEAL-42",
          "nurl": "https://dsp.example/win?imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
          "burl": "https://dsp.example/bill?imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}",
          "lurl": "https://dsp.example/loss?imp=${AUCTION_IMP_ID}&reason=${AUCTION_LOSS}"
        }
      ]
    }
  ]
}

顶层 id 必须回填请求的 id(对上号)。真正的内容在 seatbid[]

字段含义备注
impid出价对应哪个 imp必须等于请求里某个 imp.id
price出价(CPM)单位是 cur 指定的货币,每千次曝光价
adm创意标记(markup)HTML / VAST / 原生 JSON;内联交付见 §5.1
nurl赢价通知 URL见 §6
burl计费通知 URL2.5+,见 §6
lurl输价通知 URL见 §6
adomain[]落地页 / 广告主域名品牌安全与竞品屏蔽的核心依据,几乎必填
crid创意 ID交易所据此做创意审核 / 缓存
cid活动 ID
cat[]创意品类要落在媒体 bcat 屏蔽之外
w/h创意尺寸要匹配 imp 允许的尺寸
dealid命中哪个 Dealimp.pmp.deals[].id 对应;PMP/PD/PG 靠它结算
adid已审核广告的引用 ID
attr[]/api创意属性 / 支持的 API 框架如是否可展开、是否 MRAID

如果不出价,规范推荐直接回 HTTP 204 No Content(最省),或回一个带 nbr(no-bid reason)的空响应说明为什么不出——nbr 常见码:0 未知、1 技术错误、2 请求无效、3 已知爬虫、4 疑似非人类流量、8 未匹配到用户等。给出 nbr 对交易所侧的流量诊断很有价值,但也多花一点带宽,是否回是双方约定。

4. 交易所怎么结算:at 拍卖类型与成交价

这是最容易被工程新人跳过、却是整个协议语义核心的一节:DSP 只负责报 price,谁赢、成交多少,是交易所说了算。 请求里的 at(auction type)声明用哪种规则结算:

at规则成交价现状
1First Price赢家照自己出的价成交2019 年后的行业默认(见 last look)
2Second Price Plus赢家付第二名 + 一个增量legacy,仍有存量
3(历史)固定价 dealbidfloor 即成交价已废弃,deal 价改由 pmp 表达

一价与二价的博弈、以及为什么行业从二价集体倒向一价,last look 那篇已经讲透,这里只强调协议层的两个落点:

  1. 成交价 ≠ 出价(在二价下)。DSP 出 $3.20 不代表付 $3.20——所以它需要交易所把真实成交价告诉自己。这就是下一节 ${AUCTION_PRICE} 宏存在的根本原因。
  2. 底价是一道硬门槛。出价必须 ≥ imp.bidfloor(公开竞价)或 deal.bidfloor(私有交易),否则连入围都算不上——对应输价原因码里的 100 Bid Below Auction Floor / 101 Bid Below Deal Floor

OpenRTB 一次竞价往返时序图:五个参与者(媒体客户端、交易所 / SSP、DSP·A、DSP·B、DSP·C 胜者)纵向生命线;步骤自上而下——① 媒体触发广告请求;② 交易所构造 bid request 并广播给三个 DSP(标注 tmax=120ms 时间预算);③ 三个 DSP 各自在 100ms 内返回 bid response(A 出 2.1、B 出 2.8、C 出 3.2,或 204 不出价);旁注「超过 tmax 的响应直接丢弃」;④ 交易所跑拍卖:按 at 结算、校验底价/adomain/尺寸,选出 C 为胜者;⑤ 交易所回调胜者 C 的 nurl(把 {AUCTION_PRICE} 替换成结算价);⑥ 交易所把 C 的 adm 交给媒体客户端渲染;⑦ 曝光被渲染/度量后,交易所触发 C 的 burl 计费通知;⑧ 交易所回调落选者 A、B 的 lurl(带 {AUCTION_LOSS} 输价原因);底部旁注:报价的是 DSP,主持拍卖与定成交价的是交易所——这正是 last look 得以存在的协议前提 把 §3–§6 串成一条时间线:广播 → 应答 → 交易所结算 → 通知赢家(nurl)→ 渲染 → 计费(burl)→ 通知输家(lurl)。注意 tmax 是一道无情的闸门——晚到即作废;也注意拍卖发生在交易所侧,DSP 从头到尾只看得见自己那一份。

5. 创意怎么送达:adm 内联 vs nurl 拉取

赢了之后,那段真正要渲染的创意标记(markup)怎么到达页面?OpenRTB 允许两种,理解它们的取舍很重要:

5.1 adm 内联(现代主流)

DSP 在 bid response直接把 adm 填上。赢了,交易所手上已经有创意,直接下发渲染。nurl 此时只作赢价通知用(打点 + 回传成交价)。

5.2 nurl 拉取(legacy,已不推荐)

DSP 省略 adm,只给 nurl。交易所判定它赢了之后,去回调 nurl,DSP 在这个回调的响应里才返回创意标记。

一句话取舍:能内联就内联nurl 的价值早已从「送创意」退化为「送通知 + 回传价格」,别再让它扛创意交付的活。

6. 三条 notice URL:win / billing / loss 各打各的点

这是 OpenRTB 里最能体现「协议设计者踩过坑」的一组字段。为什么需要三条回调 URL?因为「赢了」「被计费」「输了」是三个不同时刻、不同用途的事件:

URL何时触发典型用途关键宏
nurl赢得拍卖时回传成交价、赢价打点${AUCTION_PRICE}
burl曝光真正可计费(渲染/度量)计费与预算扣减的权威时点${AUCTION_PRICE}
lurl输掉拍卖时拿到输价原因、优化出价模型${AUCTION_LOSS}

6.1 为什么把 nurl(赢)和 burl(计费)拆开

这是 2.5 引入 burl 的核心动机:「赢得拍卖」不等于「产生了一次真实曝光」。 赢了之后,广告可能因为用户划走太快、渲染失败、视口外没被看见(viewability)等原因根本没被计费

对 DSP 工程的落点:预算控制要以 burl 为准nurl 只能作参考——否则你会把「赢了但没曝光」的量也算进花费,导致投放数据虚高。

6.2 成交价靠 ${AUCTION_PRICE} 宏回填

回到 §4 的问题:二价下 DSP 不知道自己实际付多少。解法是宏替换——DSP 在 nurl/burl 里写占位符 ${AUCTION_PRICE},交易所在回调前把它替换成真实结算价:

DSP 提交:  https://dsp.example/win?imp=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}
交易所回调:https://dsp.example/win?imp=1&price=3.05      ← 宏被替换成真实成交价

${AUCTION_PRICE} 常被交易所加密(防止买方反推别人的出价、以及防篡改),DSP 用事先约定的密钥解密。除它之外还有一组配套宏:

6.3 lurl:输了也要知道为什么

lurl(loss notice,2.5 引入)是给 DSP 的负反馈:输了,交易所回调它并带上 ${AUCTION_LOSS} 原因码。常见码:

含义
0其实赢了(Bid Won)
1交易所内部错误
2曝光机会已过期
100出价低于公开底价
101出价低于 deal 底价
102输给了更高的出价
103输给了一个 PMP deal
2xx创意被过滤(品类/尺寸/域名等各种审核不过)

对出价模型,lurl 是极有价值的训练信号:「输给更高价(102)」意味着可以适当加价,「创意被过滤(2xx)」意味着素材本身有问题,两者的优化动作完全相反。 没有 lurl,DSP 只能靠赢单反推,盲区很大。

7. ext:协议的扩展逃生口,与「方言」的由来

OpenRTB 规定:几乎每个对象都可以带一个 ext 字段,用来放规范没有覆盖的私有数据。这是这套协议能存活十几年的关键设计——它不需要每加一个新概念就发一个大版本,交易所可以先在 ext 里试验,成熟后 IAB 再考虑收编进主规范。

历史上很多今天的「正式字段」都是从 ext 长出来的:

ext 也是**「方言」的根源**:同一个概念,交易所 A 放在 imp.ext.viewability、交易所 B 放在 imp.ext.pm.viewability,字段名和结构各不相同。这正是 Bidder 解析层必须做方言适配 / 归一化的原因——把各家 ext 里的私货,映射成引擎内部统一的一套模型,下游才不必关心它来自哪个交易所。

一句话:ext 是协议的优点也是它的税。它让 OpenRTB 灵活到能容纳整个行业的演进,也让每个买方都得为「对接第 N 个交易所」付一份归一化成本。

8. 版本演进:2.5 → 2.6 →(3.0 / AdCOM)

工程上你几乎只会碰到 2.x,但知道演进主线能帮你判断字段该去哪找:

给工程师的实用结论:按 2.6 的字段位置去理解和归一化,把 3.0/AdCOM 当作「未来的对象模型词汇表」来读——当你看到某个交易所在 ext 里用 AdCOM 风格的对象时,就不会陌生。

9. 常见误解 ↔ 正解

常见误解正解
OpenRTB 里 DSP 决定谁赢交易所主持拍卖、定胜负与成交价;DSP 只报 price
bid.price 就是最终花的钱二价下不是;真实成交价靠 ${AUCTION_PRICE} 宏回传(见 §4/§6.2)
nurl 就是用来计费的nurl=赢价通知,计费应以 burl 为准(赢 ≠ 曝光,见 §6.1)
创意必须靠 nurl 回调去拿现代主流是 adm 内联nurl 拉取是 legacy、多一跳(见 §5)
imp.id 和请求的 id 是一回事前者是广告位在本请求内的局部编号bid.impid 对它),后者是请求全局 ID
user.id = buyeruid一个是卖方视角 ID、一个是买方视角 ID,靠 cookie matching 映射
siteapp 可以同时出现互斥,只出现其一——一次曝光只在一个环境里
schain 是 2.6 才有的东西概念更早,先活在 source.ext.schain,2.6 才转正到 source.schain
输了就没有反馈lurl + ${AUCTION_LOSS} 会告诉你为什么输,是出价模型的关键训练信号
现在大家都用 OpenRTB 3.0线上主力仍是 2.5 / 2.6;3.0/AdCOM 理念先进但落地缓慢

10. 一张速查表

把全篇压成一张随手可查的表:

你关心的问题看哪个字段 / 对象出现在
要卖的广告位imp[](+ banner/video/audio/nativerequest
底价imp.bidfloor / imp.pmp.deals[].bidfloorrequest
私有交易 / Deal IDimp.pmp.deals[]id 即 Deal ID)request
曝光环境site(网页)/ app(应用),二选一request
设备 / 广告标识 / 限跟踪deviceifa/lmt/dnt/georequest
用户身份(买卖两侧)user.id / user.buyeruidrequest
供应链路径source.schain(2.6)request
隐私法规regscoppa / ext.gdpr / gpprequest
时间预算tmaxrequest
拍卖类型at1 一价 / 2 二价+)request
出价seatbid[].bid[].priceresponse
出价对应哪个位bid.impid(对上 imp.idresponse
创意标记bid.adm(内联)response
品牌安全 / 竞品bid.adomain[] / bid.cat[]response
命中的 Dealbid.dealidresponse
不出价原因nbr(+ HTTP 204)response
赢价通知 / 回传成交价bid.nurl + ${AUCTION_PRICE}notice
计费(权威花费)bid.burlnotice
输价原因bid.lurl + ${AUCTION_LOSS}notice
私有 / 未覆盖字段任意对象的 ext两端

记住三条主线,这份协议就不会乱:

  1. 请求描述机会、响应描述出价——骨架分别是 imp[]seatbid[].bid[]
  2. 交易所主持、DSP 应答——报价的是 DSP,定胜负与成交价的是交易所(这也是 last look 的协议土壤)。
  3. 赢 / 计费 / 输是三件事——nurl / burl / lurl 各打各的点,成交价靠 ${AUCTION_PRICE} 宏回传。

下次再在别的文章里看到 pmp.deals[]source.schain${AUCTION_PRICE},你就知道它们在这棵对象树上的确切位置了。


延伸阅读

同系列(Bidding 竞价引擎链路)——协议进了引擎之后:

程序化生态系列——都踩在这份协议上:

规范与一手资料:

附录:术语表


views
Share this post on:

Previous Post
Header Bidding 深挖:Prebid.js(客户端)vs Prebid Server(服务端)架构
Next Post
广告出海地区分层:T1 / T2 / T3 市场分析与打法