> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thrad.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Bid

> OpenRTB 2.6 bid endpoint your DSP must expose. Thrad SSP POSTs a BidRequest and expects a BidResponse or no-bid.

> [!NOTE]
>
> The endpoint URL is yours — configure it with the Thrad team during onboarding.

## Headers

**Content-Type** `string` *required*

Always `application/json`.

**x-openrtb-version** `string` *required*

Always `2.6` — explicit version declaration. Some DSPs gate the parser on this header; reject unsupported versions with `400`.

**Accept** `string`

`application/json` — sent on every request.

## Authentication

Two auth styles are supported per DSP — picked at onboarding and stored against your DSP config.

**Authorization** `string`

`Bearer <your-shared-key>` — provided by you at onboarding. Use this if you prefer header-based auth.

> [!NOTE]
>
> **Or:** pubid-in-URL pattern. If your endpoint identifies the publisher via a URL query parameter (e.g. `https://your-host/bid?pubid=12345`), provide the full URL with the parameter baked in at onboarding and leave the bearer token blank. We will not add any `Authorization` header in that case. NoBid is integrated this way.

> [tab] BidRequest
>
> ## Envelope
>
> **id** `string` *required*
>
> Auction id. Echo in `BidResponse.id`.
>
> **at** `integer` *required*
>
> Always `1` — first-price.
>
> **tmax** `integer` *required*
>
> The time we hold you to, in ms; responses after it are dropped. `3000` on in-conversation (`card_*`) requests and `1000` on `opener_*` requests from publishers. On supply resold from another exchange it is that exchange's `tmax` minus our reserve, capped at `3000`. Read it per request.
>
> **cur** `string[]` *required*
>
> Always `["USD"]`.
>
> **test** `integer` *required*
>
> `1` during onboarding, `0` in production.
>
> ## `imp[]`
>
> Always a single impression.
>
> **imp\[].id** `string` *required*
>
> Always `"1"`.
>
> **imp\[].tagid** `string` *required*
>
> Slot identifier. Encodes the slot and its render mode — key your bid multipliers, frequency caps, and creative eligibility off this together with `site.id`. One of:
>
> | tagid | Slot | Mode | DSP must populate |
> | - | - | - | - |
> | `card_image` | In-conversation card | Image preferred (logo also accepted) | Asset 3 (image) **or** asset 4 (icon) |
> | `card_text` | In-conversation card | Text only — image will be rejected server-side | Asset 4 (icon/logo) — required |
> | `opener_image` | Pre-chat opener | Image preferred (logo also accepted) | Asset 3 (image) **or** asset 4 (icon) |
> | `opener_text` | Pre-chat opener | Text only — image will be rejected server-side | Asset 4 (icon/logo) — required |
>
> > [!WARNING]
> >
> > Until 2026-10 these were spelled `sponsored_message_{image,text}_v1` and `opener_{image,text}_v1`. Match on the `_image` / `_text` suffix rather than the full string if you want to be robust to the change.
>
> **imp\[].bidfloor** `float` *required*
>
> Floor in CPM USD. A bid at or above it is never rejected on price. On supply Thrad resells from another exchange the floor already includes Thrad's margin, so you do not need to bid above it to cover the downstream floor.
>
> **imp\[].bidfloorcur** `string` *required*
>
> Always `"USD"`.
>
> > [!NOTE]
> >
> > **Fields Thrad intentionally does not send:**
> >
> > * `imp[].pos` — none of the oRTB position enums (above-fold, full-screen, etc.) cleanly describe inline chatbot inventory. Route on `imp[].tagid` and the Native asset list instead.
> > * `imp[].battr` — the Native 1.2 asset shape Thrad requests already structurally rejects video, audio, expandable, and pop creatives. Sending `battr` would be noise on top.
>
> **imp\[].secure** `integer` *required*
>
> Always `1`. All URLs in your response must be HTTPS.
>
> **imp\[].native.ver** `string` *required*
>
> Always `"1.2"` (outer ver double-declaration). The inner `native.request` envelope also carries `ver: "1.2"`.
>
> **imp\[].native.request** `string` *required*
>
> JSON-encoded Native 1.2 request object. Shape forks by render mode (signalled in `imp[].tagid`) — the publisher's `image_enabled` config drives both the tagid and the asset list, so they're always consistent.
>
> **Image mode** (`tagid` ends in `_image`) — accepts both image and text creatives:
>
> ```json theme={null}
> {
>   "native": {
>     "ver": "1.2",
>     "context": 1,
>     "contextsubtype": 10,
>     "plcmttype": 4,
>     "plcmtcnt": 1,
>     "assets": [
>       { "id": 0, "required": 1, "title": { "len": 90 } },
>       { "id": 1, "required": 1, "data": { "type": 2, "len": 200 } },
>       { "id": 2, "required": 1, "data": { "type": 1, "len": 50 } },
>       { "id": 3, "required": 0, "img": { "type": 3, "wmin": 400, "hmin": 400, "mimes": ["image/jpeg", "image/png", "image/webp"] } },
>       { "id": 4, "required": 0, "img": { "type": 1, "wmin": 100, "hmin": 100, "mimes": ["image/jpeg", "image/png", "image/webp"] } },
>       { "id": 5, "required": 1, "data": { "type": 12, "len": 15 } }
>     ],
>     "eventtrackers": [{ "event": 1, "methods": [1] }],
>     "privacy": 1
>   }
> }
> ```
>
> **Text mode** (`tagid` ends in `_text`) — publisher cannot render images. Asset 3 is **omitted entirely** and asset 4 (logo) becomes required:
>
> ```json theme={null}
> "assets": [
>   { "id": 0, "required": 1, "title": { "len": 90 } },
>   { "id": 1, "required": 1, "data": { "type": 2, "len": 200 } },
>   { "id": 2, "required": 1, "data": { "type": 1, "len": 50 } },
>   { "id": 4, "required": 1, "img": { "type": 1, "wmin": 100, "hmin": 100, "mimes": ["image/jpeg", "image/png", "image/webp"] } },
>   { "id": 5, "required": 1, "data": { "type": 12, "len": 15 } }
> ]
> ```
>
> Asset `id`s are **0-based** (title=`0` … CTA=`5`) — your response must echo the same ids the request sent.
>
> **Required assets in both modes:** 0 (title), 1 (description), 2 (sponsoredBy), 5 (CTA). Bids missing any of these are rejected.
>
> **Asset 4 (logo):** required in text mode, optional in image mode.
>
> **Asset 3 (main image):** only present (and only acceptable in your response) in image mode. If you return `image_url` on a text-mode request, the SSP **drops it server-side** before render — a logo-only creative will still serve, but if you sent neither asset 4 nor a usable image, the bid is silently lost. Always honour the tagid signal.
>
> **`title.len`** is parameterised by the publisher's `max_headline_chars` setting (≥ 30, default 90). The other `len` values and image dimensions are global per format. `len` is a **maximum** character count; `wmin`/`hmin` are minimum image dimensions in pixels.
>
> **imp\[].native.request\_native** `string`
>
> **Non-standard convenience duplicate.** Byte-for-byte identical to `imp[].native.request` — the same *stringified* Native 1.2 envelope, **not** an object. Native 1.2 defines only `request`; we additionally send `request_native` because some bidders key their native parser on this field name and reject the spec field with a `bad adslot` error. **If your bidder already reads `native.request`, ignore this field** — it carries identical content and is provided purely for parser compatibility.
>
> **imp\[].ext.thrad** `object`
>
> Thrad extension — what Thrad shares about the conversation. It lives under `ext` because no standard oRTB field describes a conversation: `site.cat` / `site.keywords` describe the site. Spec-strict bidders can ignore it; none of these affect SSP-side auction logic. The slot and its render mode are in `imp[].tagid`, not here.
>
> > [!NOTE]
> >
> > In 2026-10 the conversation signals moved here from `site.keywords`, `site.cat` and `user.keywords`, which are no longer sent, and `n_ads_before`, `prefetched` and `request_type` were removed. `site.search` (the rephrased conversation) is no longer sent at all: target on `keywords`, `intent`, `task` and `iab_tier1`.
>
> **fields**
>
> **chat\_id** `string`
>
> Conversation id. Stable across turns within the same chat, so it is the key for capping or rotating creatives within one conversation. Omitted on `opener` (no chat exists yet).
>
> **keywords** `string[]`
>
> Topical keywords extracted from the conversation, lowercased, deduplicated, at most 20. Omitted when empty.
>
> **intent** `string`
>
> Thrad's intent classification of the conversation (for example `shopping`). Omitted when not classified.
>
> **task** `string`
>
> What the user is doing, as one of Thrad's task slugs (for example `comm.professional_email`). Omitted when unknown.
>
> **iab\_tier1** `string`
>
> IAB Content Taxonomy 3.0 Tier 1 category name for the conversation (for example `Technology & Computing`). Omitted when unknown.
>
> ## `site` / `app`
>
> Exactly one of `site` or `app` is present per request. `app` ships when the publisher's inventory is in-app (`ios_app` / `android_app`); otherwise `site`. The `publisher` subblock is identical in both.
>
> **site.id** `string` *required*
>
> Opaque publisher slot id. Resolved in priority: (1) per-DSP `external_site_id` override if your DSP requires a fixed id (set at onboarding), (2) the chatbot's `public_id` (UUID), (3) stringified internal publisher id (legacy fallback). Stable for a given chatbot.
>
> **site.domain** `string`
>
> Hostname where the chatbot is rendered (lowercased, scheme stripped). Derived from the publisher's per-request URL. Omitted when the publisher doesn't provide a URL.
>
> **site.page** `string`
>
> Full page URL where the chatbot is rendered. Omitted when not provided.
>
> **site.privacypolicy** `integer` *required*
>
> Always `1`. The Thrad network operates under a privacy policy.
>
> **site.publisher.id** `string` *required*
>
> Opaque publisher organisation id. Resolved in priority: (1) per-DSP `external_site_id` override, (2) the organisation's `public_id` (UUID), (3) stringified internal publisher id. Equals `site.id` when override (1) is in play; differs otherwise.
>
> **site.publisher.name** `string` Organisation name, **lowercased**. Adtech allow/denylists key on string equality — we normalise upstream so you don't have to. Omitted when unset.
> **site.publisher.domain** `string` Organisation domain, **lowercased**, scheme + path stripped. Omitted when unset. Different concept from `site.domain` — this is the publisher organisation's domain (the sellers.json target); `site.domain` is where the ad runs.
> `site.cat`, `site.cattax`, `site.search` and `site.keywords` are not sent: they describe a site, and the conversation is in `imp[].ext.thrad`.
>
> ### `app` block (in-app inventory only)
>
> Same `id` / `publisher` semantics as `site`. Additionally:
>
> **app.bundle** `string`
>
> iOS bundle ID (`com.example.app`) or Android package name. Required for in-app inventory.
>
> **app.storeurl** `string`
>
> App Store / Play Store listing URL.
>
> **app.domain** `string`
>
> App marketing site domain — reuses the publisher organisation's domain (same value as `app.publisher.domain`).
>
> ## `device`
>
> **device.ua** `string` *required*
>
> End-user User-Agent.
>
> **device.ip** `string`
>
> End-user IPv4. Omitted when the user is on IPv6 (see `device.ipv6`).
>
> **device.ipv6** `string`
>
> End-user IPv6. Sent instead of `device.ip` when applicable.
>
> **device.devicetype** `integer` *required*
>
> `2`=desktop, `4`=phone, `5`=tablet, `0` for unknown.
>
> **device.js** `integer` *required*
>
> Always `1`. Thrad's chatbot widget is JS-rendered — `noscript` fallbacks are never needed.
>
> **device.language** `string`
>
> ISO 639-1 alpha-2. Priority: (1) conversation language auto-detected from `messages` (best for in-chat ads), (2) Accept-Language header parsed from the publisher request (fallback for openers and sparse conversations). Field is omitted entirely when neither signal is available.
>
> **device.geo.country** `string`
>
> ISO-3 country code.
>
> **device.geo.region** `string`
>
> Region/state, when available.
>
> **device.geo.city** `string`
>
> City, when available.
>
> **device.geo.type** `integer`
>
> Always `2` (IP-based) when the `geo` block is present. Thrad does not collect GPS-grade geolocation.
>
> **device.os** `string`
>
> OS string, UA-parsed.
>
> **device.ifa** `string`
>
> Advertising id (IDFA/GAID). In-app only.
>
> **device.dnt** `integer`
>
> `1` if Do Not Track.
>
> **device.lmt** `integer`
>
> `1` if Limit Ad Tracking.
>
> ## `user`
>
> **user.id** `string` *required*
>
> Anonymous Thrad user id.
>
> **user.buyeruid** `string`
>
> DSP cookie-match value, when available via cookie sync.
>
> **user.ext.consent** `string`
>
> IAB TCF v2.x consent string. Present when `regs.gdpr=1`.
>
> ## `regs`
>
> **regs.gdpr** `integer` *required*
>
> `1` when the user's country falls under GDPR / UK GDPR / EEA / Swiss FADP (based on Thrad SSP's IP-geo lookup), `0` otherwise. **`user.ext.consent` is not yet sent**; TCF v2 consent forwarding is planned. EU-compliant DSPs that require a consent string on `gdpr=1` should refuse to bid on those requests (the correct behavior); we explicitly declare `1` so eligibility logic isn't ambiguous.
>
> **regs.us\_privacy** `string`
>
> CCPA string, when supplied by the publisher. Not yet sent.
>
> **regs.gpp** `string`
>
> GPP string, when supplied by the publisher. Not yet sent.
>
> **regs.coppa** `integer`
>
> `1` if user is flagged under-13. Not yet sent.
>
> ## `source`
>
> **source.fd** `integer` *required*
>
> Always `0`. SSP is the entity making the auction decision.
>
> **source.tid** `string` *required*
>
> Transaction id. Echoes `BidRequest.id`.
>
> **source.schain** `object`
>
> IAB SupplyChain object (sellers.json chain) — oRTB 2.6 location (root `source`, not `source.ext`). Sent on every request where the publisher's organisation `public_id` is available. Single-node chain today: `asi` is Thrad's host (matches our `sellers.json`), `sid` is the publisher organisation `public_id`, `hp: 1`. Verify the `asi` value resolves to a `seller_id` entry at `https://<asi>/sellers.json` if you do supply-chain validation.
>
> ## `bcat` / `badv`
>
> **bcat** `string[]`
>
> Blocked IAB categories. Only present when set.
>
> **badv** `string[]`
>
> Blocked advertiser domains. Only present when set.
>
> ```json BidRequest theme={null}
> {
>   "id": "auction_987654321",
>   "at": 1,
>   "tmax": 3000,
>   "cur": ["USD"],
>   "test": 0,
>   "site": {
>     "id": "pub_42",
>     "domain": "yourchatbot.com",
>     "page": "https://yourchatbot.com/chat",
>     "publisher": { "id": "pub_42", "name": "Your Chatbot", "domain": "yourchatbot.com" }
>   },
>   "device": {
>     "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
>     "ip": "192.168.1.1",
>     "devicetype": 2,
>     "os": "OSX",
>     "geo": { "country": "USA", "region": "CA", "city": "San Francisco", "type": 2 },
>     "language": "en"
>   },
>   "user": {
>     "id": "hashed_user_id_xyz987",
>     "ext": { "consent": "<TCF v2 string when GDPR applies>" }
>   },
>   "regs": { "gdpr": 0 },
>   "source": {
>     "fd": 0,
>     "tid": "auction_987654321",
>     "schain": { "complete": 1, "ver": "1.0", "nodes": [
>       { "asi": "thrad.ai", "sid": "<org-public-id>", "hp": 1 }
>     ]}
>   },
>   "imp": [{
>     "id": "1",
>     "tagid": "card_image",
>     "secure": 1,
>     "bidfloor": 4.00,
>     "bidfloorcur": "USD",
>     "native": {
>       "request": "{\"ver\":\"1.2\",\"context\":1,\"contextsubtype\":10,\"plcmttype\":4,\"plcmtcnt\":1,\"assets\":[{\"id\":0,\"required\":1,\"title\":{\"len\":90}},{\"id\":1,\"required\":1,\"data\":{\"type\":2,\"len\":200}},{\"id\":2,\"required\":1,\"data\":{\"type\":1,\"len\":50}},{\"id\":3,\"required\":0,\"img\":{\"type\":3,\"wmin\":400,\"hmin\":400,\"mimes\":[\"image/jpeg\",\"image/png\",\"image/webp\"]}},{\"id\":4,\"required\":0,\"img\":{\"type\":1,\"wmin\":100,\"hmin\":100,\"mimes\":[\"image/jpeg\",\"image/png\",\"image/webp\"]}},{\"id\":5,\"required\":1,\"data\":{\"type\":12,\"len\":15}}],\"eventtrackers\":[{\"event\":1,\"methods\":[1]}],\"privacy\":1}",
>       "request_native": "{\"ver\":\"1.2\",\"context\":1,\"contextsubtype\":10,\"plcmttype\":4,\"plcmtcnt\":1,\"assets\":[{\"id\":0,\"required\":1,\"title\":{\"len\":90}},{\"id\":1,\"required\":1,\"data\":{\"type\":2,\"len\":200}},{\"id\":2,\"required\":1,\"data\":{\"type\":1,\"len\":50}},{\"id\":3,\"required\":0,\"img\":{\"type\":3,\"wmin\":400,\"hmin\":400,\"mimes\":[\"image/jpeg\",\"image/png\",\"image/webp\"]}},{\"id\":4,\"required\":0,\"img\":{\"type\":1,\"wmin\":100,\"hmin\":100,\"mimes\":[\"image/jpeg\",\"image/png\",\"image/webp\"]}},{\"id\":5,\"required\":1,\"data\":{\"type\":12,\"len\":15}}],\"eventtrackers\":[{\"event\":1,\"methods\":[1]}],\"privacy\":1}"
>     },
>     "ext": {
>       "thrad": {
>         "chat_id": "chat_456",
>         "keywords": ["crm", "software", "startup", "b2b"],
>         "intent": "shopping",
>         "task": "comm.professional_email",
>         "iab_tier1": "Technology & Computing"
>       }
>     }
>   }]
> }
> ```

> [tab] BidResponse
>
> ## Envelope
>
> **id** `string` *required*
>
> Must echo `BidRequest.id`.
>
> **cur** `string` *required*
>
> Must be `"USD"`.
>
> **seatbid** `array` *required*
>
> Multiple `seatbid` entries and multiple `bid[]` per seatbid are accepted. The SSP picks the **highest-priced bid across all seatbids** as your DSP's representative for the cross-DSP auction. Ties are broken by first encountered. Bids with `bid.cur` set to anything other than `"USD"` are dropped before pricing. Non-winning bids in the same response still get an `lurl` notification — see [Notifications](#notifications) below.
>
> ## `seatbid[]`
>
> **seat** `string`
>
> Buyer seat id. Identifies which buyer at your DSP placed the bid (e.g. agency, advertiser, or trading desk). Surfaced in Thrad analytics for per-seat revenue and quality slicing — recommended whenever your DSP fronts multiple buyers. Optional; omit or send empty if you have no seat concept.
>
> ## `seatbid[].bid[]`
>
> **id** `string` *required*
>
> Your unique bid id. Correlated with `nurl`, `lurl`, `burl`.
>
> **impid** `string` *required*
>
> Must echo `"1"` (Thrad sends a single impression per request). Non-`"1"` values are accepted today but logged for follow-up; strict envelope validation (v2) will hard-reject.
>
> **price** `float` *required*
>
> CPM USD. Must be ≥ `imp.bidfloor`. 2 decimal places — bids with more precision are rounded using `ROUND_HALF_UP` (see [Pricing & Commission](https://docs.thrad.ai/dsp/guides/ortb/overview.md#pricing-commission)).
>
> **adm** `string` *required*
>
> JSON-encoded Native 1.2 ad markup. Both wrapper styles are accepted — the SSP auto-detects which one your DSP emits:
>
> **Spec-correct Native 1.2 (unwrapped, recommended):**
>
> ```json theme={null}
> {
>   "ver": "1.2",
>   "assets": [
>     { "id": 0, "title": { "text": "Best CRM for startups" } },
>     { "id": 1, "data": { "value": "Try HubSpot free for 30 days." } },
>     { "id": 2, "data": { "value": "HubSpot" } },
>     { "id": 3, "img": { "url": "https://your-dsp.com/img/hero.jpg", "w": 1200, "h": 627 } },
>     { "id": 4, "img": { "url": "https://your-dsp.com/img/logo.png", "w": 100, "h": 100 } },
>     { "id": 5, "data": { "value": "Try Free" } }
>   ],
>   "link": { "url": "https://your-dsp.com/click/abc123?price=${AUCTION_PRICE}" },
>   "eventtrackers": [
>     { "event": 1, "method": 1, "url": "https://your-dsp.com/imp/abc123" }
>   ]
> }
> ```
>
> **Legacy Native 1.0/1.1 (wrapped, also accepted):**
>
> ```json theme={null}
> { "native": { "ver": "1.2", "assets": [ ... ], "link": { ... }, "eventtrackers": [ ... ] } }
> ```
>
> Native 1.2 §5.1 deprecated the outer `native` wrapper, but a meaningful share of DSPs still emit it. The SSP peels the wrapper when present and processes either shape identically. If `adm.ver` is set to anything other than `"1.2"`, a warning is logged and the bid still flows.
>
> **Impression trackers.** The SSP reads Native 1.2 `eventtrackers` entries where `event=1` (impression) **and** `method=1` (image pixel), and also merges the legacy `native.imptrackers[]` array (Native 1.0/1.1 compat) — bare URL strings, treated as `event=1` + `method=1`. Both spellings are read together and de-duplicated by URL, so the same URL sent in both fires once.
>
> The bid request advertises what we can fire, per Native 1.2 §7.6, and that set is deliberately narrow:
>
> ```json theme={null}
> "eventtrackers": [{ "event": 1, "methods": [1] }]
> ```
>
> That is the complete set. **`event=2` (viewable) is not declared and not supported**, and neither is `method=2` (JavaScript). Trackers you send for an event or method we did not advertise are discarded.
>
> Three further rules apply to the URLs you send:
>
> * **HTTPS only.** Non-HTTPS tracker URLs are silently dropped.
> * **Macros are expanded** — `${AUCTION_ID}`, `${AUCTION_BID_ID}`, `${AUCTION_IMP_ID}`, `${AUCTION_PRICE}`, `${AUCTION_CURRENCY}`. Any other macro is left in the URL verbatim.
> * **Only the first surviving tracker is fired.** The publisher response carries one URL per tracking parameter, so a bid declaring several impression trackers gets exactly one of them fired. Send your primary counting pixel first.
>
> Asset ids must match those requested (0-based). Missing required assets (0, 1, 2, 5) reject the bid.
>
> | Asset id | Publisher field |
> | - | - |
> | 0 (title) | `headline` |
> | 1 (data.desc) | `description` |
> | 2 (data.sponsoredBy) | `advertiser` |
> | 3 (img.main) | `image_url` |
> | 4 (img.icon) | `logo_url` |
> | 5 (data.ctatext) | `cta_text` |
> | `link.url` | `url` (wrapped through Thrad click redirect) |
> | `eventtrackers[event=1].url` | `ext_view_url` (see [Viewability](#viewability-and-impression-counting)) |
>
> **adomain** `string[]` *required*
>
> Advertiser domain(s). Used for brand-safety. Required and must be non-empty — bids with empty `adomain` log a warning today and are hard-rejected under strict envelope validation (v2).
>
> **nurl** `string`
>
> Win notification URL. Fired once on win, before delivery. Include `${AUCTION_PRICE}`. **Must be HTTPS** — non-HTTPS URLs are dropped server-side (the bid still wins, only the notification is skipped).
>
> **lurl** `string`
>
> Loss notification URL. Fired once on loss. Include `${AUCTION_LOSS}`. **Must be HTTPS** — non-HTTPS URLs are dropped.
>
> **burl** `string`
>
> Billing URL. Fired once after publisher confirms impression rendered. Use for spend recognition. **Must be HTTPS** — non-HTTPS URLs are dropped.
>
> **cid** `string`
>
> Campaign id. Recorded on the impression for analytics.
>
> **crid** `string`
>
> Creative id. Recorded on the impression for analytics.
>
> **cat** `string[]`
>
> IAB category for the creative. First entry is recorded on the impression for analytics.
>
> ## Server validation
>
> Limits and checks the SSP applies to your response. Today most are **log-only** (the bid still flows) so DSPs get visibility before strict mode lands; strict envelope validation (v2) flips the listed checks to hard rejects.
>
> | Check | Behaviour today | Notes |
> | - | - | - |
> | Response body size | Hard cap **1 MB** (rejected before JSON parse) | Real oRTB responses are 1–10 KB; the cap is generous |
> | `seatbid[]` count | Hard cap **10** — extras are truncated with a log | Real responses send 1–3 |
> | `bid[]` per seatbid | Hard cap **20** — extras are truncated with a log | Real responses send 1–5 |
> | `BidResponse.id` echoes `BidRequest.id` | Log-only | Will hard-reject under strict mode |
> | `cur == "USD"` (envelope) | Log-only | Per-bid `bid.cur != "USD"` is dropped today |
> | `bid.impid == "1"` | Log-only | Will hard-reject under strict mode |
> | `bid.adomain` non-empty | Log-only | Will hard-reject under strict mode |
> | `adm.ver == "1.2"` | Log-only | Other versions accepted but flagged |
> | `nurl` / `lurl` / `burl` are HTTPS | URL dropped, bid still wins | Non-HTTPS leaks auction data in cleartext |
> | Asset shape matches request (id → outer/inner) | Log-only | Surfaces drift before it degrades to `ortb_no_renderable_asset` |
> | Image asset dimensions ≥ requested `wmin` / `hmin` | Log-only | Declared dims only — fetched audit lands later |
> | Text asset length ≤ requested `len` | Log-only | Title limit is per-publisher (`max_headline_chars`) |
>
> ## Notifications
>
> All three are fire-and-forget GETs (\~2 s timeout, no retry). Errors are logged and swallowed — no part of the auction or render path waits on them.
>
> | URL | When fired | Use for |
> | - | - | - |
> | `nurl` | Auction win, after creative is committed for render | Bid bookkeeping, pacing |
> | `lurl` | Auction loss (cross-DSP **and** in-response) | Bidder tuning, price learning |
> | `burl` | Publisher confirms a **viewable** impression — see [Viewability](#viewability-and-impression-counting) | Spend recognition, billing |
>
> ### Loss notifications (`lurl`) for multi-bid responses
>
> The SSP fires `lurl` in two situations and your endpoint should be able to handle both:
>
> * **Cross-DSP loser** — your DSP's representative bid (highest-priced bid in your response) lost the cross-DSP auction to another DSP. Reason code `102` ("lost to higher bid").
> * **In-response loser** — a non-winning bid inside your own response (you sent multiple bids in one or more seatbids; only the highest-priced one became your representative). Each in-response loser gets its own `lurl` fired with reason code `102`. This happens regardless of whether your representative bid won the cross-DSP auction — i.e. even if your DSP is the overall winner, your other bids in the same response still receive a loss notice.
>
> Bids dropped before pricing (non-USD `bid.cur`, missing `price`) do **not** receive an `lurl`.
>
> **Loss reason codes (`${AUCTION_LOSS}`):**
>
> | Code | Meaning | When |
> | - | - | - |
> | 102 | Lost to a higher bid (cross-DSP or in-response) | A valid bid that was outbid |
> | 100 | Bid below floor | `price` under `imp.bidfloor` after rounding. Fired even when no bid won the auction |
> | 3 | Invalid bid response | Unparseable `price`, missing bid `id`, unusable native markup, or a creative format the opportunity cannot render |
> | 205 | Advertiser blocked | `adomain` matched the request's `badv` |
> | 209 | Category blocked | `cat` matched the request's `bcat` |
> | 215 | App blocked | `bundle` matched the request's `bapp` |
>
> > [!NOTE]
> >
> > Codes `1`, `6` and `7` are not sent. Blocked advertisers are reported as `205`, and bad markup as `3`. Every bid that carries an `lurl` and does not win receives exactly one of the codes above.
>
> **Price macros** — substituted in `nurl`, `lurl`, `burl`, `adm`, and `adm.link.url`:
>
> | Macro | Value |
> | - | - |
> | `${AUCTION_PRICE}` | Clearing price (CPM USD, 2 decimals) |
> | `${AUCTION_ID}` | `BidRequest.id` |
> | `${AUCTION_BID_ID}` | `bid.id` |
> | `${AUCTION_IMP_ID}` | Always `"1"` |
> | `${AUCTION_CURRENCY}` | `"USD"` |
> | `${AUCTION_LOSS}` | Loss reason code |
>
> ## Viewability and impression counting
>
> **Thrad counts an impression when it becomes viewable, not when it renders.** Viewable means the IAB/MRC standard: at least **50% of the ad's pixels in view for at least 1 continuous second**. A creative that renders but is never viewed is never a countable impression here — no tracker fires and no `burl` fires.
>
> Your impression tracker is delivered to the publisher as the **`ext_view_url`** parameter on the click URL and is fired **client-side by the publisher**, from the end user's device, at that viewable moment.
>
> Two consequences worth planning for:
>
> * **Your `event=1` tracker fires later than on a render-counting exchange.** It is deliberately conservative and never overcounts. Your impression count will land close to your viewable count, so your measured viewability rate against Thrad inventory will look near 100%. That is expected, not a discrepancy.
> * **Your impressions reconcile with your spend**, because `burl` fires at the same viewable moment as the tracker.
>
> Because the pixel is fired from the device rather than from Thrad's servers, it arrives with the **end user's real IP address and user agent** — usable for your own IVT and fraud filtering.
>
> > [!NOTE]
> >
> > Thrad fires `nurl`, `lurl` and `burl` from its own servers. Measurement pixels are never fired server-side, so a tracker that expects a datacentre IP will see a residential or mobile one instead.
>
> Publishers on the v2 response receive each tracker with its event: `event=1` fires when the ad is drawn, `event=2` at the viewable moment. Publishers still on the v1 response receive only the click-URL parameter, fired at the viewable moment.
>
> ## No-bid
>
> | Option | When to use |
> | - | - |
> | `204 No Content` | Default. Lowest overhead. |
> | `200` + `nbr` | When reason code helps your analytics. |
> | `200` + empty `seatbid: []` | Legacy fallback. |
>
> Common `nbr` codes: `7` blocked publisher, `8` unmatched user, `9` reader cap, `10` DSP bid cap.
>
> ```json 200 - Bid (single) theme={null}
> {
>   "id": "auction_987654321",
>   "cur": "USD",
>   "seatbid": [{
>     "seat": "your-dsp",
>     "bid": [{
>       "id": "bid_abc123",
>       "impid": "1",
>       "price": 7.50,
>       "adm": "{\"native\":{\"ver\":\"1.2\",\"assets\":[{\"id\":1,\"title\":{\"text\":\"Best CRM for startups\"}},{\"id\":2,\"data\":{\"value\":\"Try HubSpot free for 30 days.\"}},{\"id\":3,\"data\":{\"value\":\"HubSpot\"}},{\"id\":4,\"img\":{\"url\":\"https://your-dsp.com/img/hero.jpg\",\"w\":1200,\"h\":627}},{\"id\":5,\"img\":{\"url\":\"https://your-dsp.com/img/logo.png\",\"w\":100,\"h\":100}},{\"id\":6,\"data\":{\"value\":\"Try Free\"}}],\"link\":{\"url\":\"https://your-dsp.com/click/abc123?price=${AUCTION_PRICE}\"},\"eventtrackers\":[{\"event\":1,\"method\":1,\"url\":\"https://your-dsp.com/imp/abc123\"}]}}",
>       "adomain": ["hubspot.com"],
>       "crid": "creative_42",
>       "nurl": "https://your-dsp.com/win?bid=${AUCTION_BID_ID}&price=${AUCTION_PRICE}",
>       "lurl": "https://your-dsp.com/loss?bid=${AUCTION_BID_ID}&reason=${AUCTION_LOSS}",
>       "burl": "https://your-dsp.com/billed?bid=${AUCTION_BID_ID}&price=${AUCTION_PRICE}"
>     }]
>   }]
> }
> ```
>
> ```json 200 - Multi-bid (multiple seats) theme={null}
> {
>   "id": "auction_987654321",
>   "cur": "USD",
>   "seatbid": [
>     {
>       "seat": "buyer_acme",
>       "bid": [
>         {
>           "id": "bid_acme_1",
>           "impid": "1",
>           "price": 7.50,
>           "adm": "...",
>           "adomain": ["acme.com"],
>           "crid": "creative_acme_42",
>           "nurl": "https://your-dsp.com/win?bid=${AUCTION_BID_ID}&price=${AUCTION_PRICE}",
>           "lurl": "https://your-dsp.com/loss?bid=${AUCTION_BID_ID}&reason=${AUCTION_LOSS}",
>           "burl": "https://your-dsp.com/billed?bid=${AUCTION_BID_ID}&price=${AUCTION_PRICE}"
>         },
>         {
>           "id": "bid_acme_2",
>           "impid": "1",
>           "price": 6.80,
>           "adm": "...",
>           "adomain": ["acme.com"],
>           "crid": "creative_acme_43",
>           "lurl": "https://your-dsp.com/loss?bid=${AUCTION_BID_ID}&reason=${AUCTION_LOSS}"
>         }
>       ]
>     },
>     {
>       "seat": "buyer_globex",
>       "bid": [{
>         "id": "bid_globex_1",
>         "impid": "1",
>         "price": 5.25,
>         "adm": "...",
>         "adomain": ["globex.com"],
>         "crid": "creative_globex_7",
>         "lurl": "https://your-dsp.com/loss?bid=${AUCTION_BID_ID}&reason=${AUCTION_LOSS}"
>       }]
>     }
>   ]
> }
> ```
>
> In the multi-bid example: `bid_acme_1` (\$7.50) is your DSP's representative for the cross-DSP auction. `bid_acme_2` and `bid_globex_1` are in-response losers — both will receive an `lurl` GET with reason `102` regardless of whether `bid_acme_1` wins or loses overall.
>
> ```json 204 - No Bid (recommended) theme={null}
> ```
>
> ```json 200 - No Bid with reason theme={null}
> {
>   "id": "auction_987654321",
>   "nbr": 102
> }
> ```

***

> [!NOTE]
>
> *Last updated: 2026-06-14 — `source.schain`, `regs.gdpr` (country-based), headers, and pubid-in-URL auth reflect production behavior since the oRTB v1 launch.*


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.