# Trillboards Demand API

DSP buying, programmatic serving and the FEIN signal surface.

Everything you can call as a **buyer**: the DSP surface (onboarding,
inventory discovery, bidding, creatives, campaigns, deals, reporting) and the
programmatic serving surface (OpenRTB delivery, impression / event tracking,
FEIN audience signals).

Two previously separate references, merged. Where both documented the same
endpoint, the DSP framing is canonical — see below.

All paths are absolute against `https://api.trillboards.com`.

- Interactive reference: https://api.trillboards.com/docs#demand
- OpenAPI document: https://api.trillboards.com/docs/openapi/bundled/demand.json
- Every operation, machine-readable: https://api.trillboards.com/api/discover?surface=demand

## Authentication

- `ApiKeyAuth`: API key in header `x-api-key`. Backwards-compatible header for the same centralized platform API key: `x-api-key: <platform_api_key>` Prefixes are not authorization. Existing prefixed keys continue to work.
- `BearerAuth`: HTTP bearer. Canonical authentication for MCP, reporting, inventory, deals, and OpenRTB control plane: `Authorization: Bearer <platform_api_key>` Self-mint the key in the Earner Portal and reuse it across these surfaces.
- `apiKeyAuth`: API key in header `X-API-Key`. FEIN API key with read or write permissions
- `bearerAuth`: HTTP bearer. JWT bearer token for protected analytics/admin routes
- `deviceTokenAuth`: API key in header `X-Device-Token`. Per-device JWT issued at pairing. This endpoint also requires `X-Device-Timestamp` and a unique `X-Device-Nonce`, and verifies that the token's registered screen matches the `screenId` path parameter.
- `dspApiKeyAuth`: API key in header `x-api-key`. The same platform API key, sent as a header instead. `X-API-Key` and `X-Trillboards-API-Key` are both accepted; `Authorization: Bearer` wins when more than one is present. Key prefixes are not authorization.
- `dspBearerAuth`: HTTP bearer. Canonical credential for the `/openrtb/v2` DSP surface (inventory discovery, stats, audience intelligence): `Authorization: Bearer <platform_api_key>` This is a centralized **platform API key**, not the JWT that `bearerAuth` describes — the two are different credentials and are not interchangeable. Self-mint the key in the Earner Portal; it carries the `DSP_DEFAULT_SCOPES` every onboarded seat gets, including `inventory:read` and `stats:read`.
- `exchangeBearerAuth`: HTTP bearer. The same exchange key, sent as `Authorization: Bearer <key>`.
- `exchangeKeyAuth`: API key in header `X-API-Key`. The exchange key for OpenRTB 2.6 bid requests to `POST /openrtb/v1/bid`, issued by Trillboards to the exchange connection. A request without it, or with a different key, is refused `401`.

## Operations (120)

### Onboarding

Self-serve DSP registration and API key management.

Register in a single API call — no manual approval required. You receive an API key
immediately and start in sandbox mode with full API access. Sandbox bids are validated
and scored but do not enter the live auction, so you can integrate safely.

**Lifecycle:** sandbox -> approved -> (suspended | revoked)

To upgrade from sandbox to production, contact developers@trillboards.com with your
seat ID and expected daily bid volume. Production DSPs get 10x higher rate limits and
their bids enter the live waterfall auction.

#### POST /openrtb/v2/onboard

Register a new DSP

Self-serve DSP registration. Recommended flow: self-mint one platform API
key in Earner Settings → Partner API, send it as `Authorization: Bearer <key>`, and this
endpoint links that same key to the DSP seat and grants the DSP/MCP scopes.
It does not return or rotate the raw key in this flow.

For backwards compatibility only, a request with no API key still mints a
`tb_dsp_*` key and shows it once. A supplied but invalid key is rejected; it
never falls back to minting a new credential.

**Sandbox mode** provides full API access for integration testing. Bids are validated
and scored but do not enter the live waterfall auction. Contact
developers@trillboards.com to upgrade to production.

The `seat_id` is your permanent OpenRTB buyer seat identifier. It must be unique
across all registered DSPs and cannot be changed after registration. Use a short,
memorable identifier like your company abbreviation.

**Rate limit:** 5 requests per hour (applies to both successful and failed attempts).

- Auth:  or `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `onboard`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `billing_email` | `string` |  | Billing contact email for invoices and payment notifications. If not provided, billing communications go to the contact_email. |
| `company_name` | `string` | yes | Your company or organization name. This appears in auction logs and reporting dashboards. Use your legal entity name. |
| `contact_email` | `string` | yes | Primary technical contact email. Used for API key recovery, sandbox-to-production upgrade notifications, and integration support. Must be a valid email address. |
| `notification_url` | `string` |  | HTTPS endpoint for win/loss notification webhooks. When your bid wins or loses an auction, a POST request is sent to this URL with the bid ID, clearing price, and screen details. Must be HTTPS. |
| `openrtb_version` | `string`: one of `2.5`, `2.6` |  | OpenRTB protocol version your DSP supports. Currently only version 2.6 is supported. Defaults to "2.6" if not specified. |
| `seat_id` | `string` | yes | Unique OpenRTB buyer seat identifier. Must be alphanumeric with hyphens and underscores only, between 2 and 64 characters. This becomes your permanent identifier in all bid requests and auction logs. Cannot be changed after registration. |
| `webhook_url` | `string` |  | HTTPS endpoint for general event webhooks (creative moderation results, deal status changes, campaign updates). Must be HTTPS. |

Responses:

- `201` DSP registered successfully. When a platform key was supplied, the response identifies it as reused and never echoes the raw secret. Legacy unauthenticated onboarding returns a newly minted key once.
- `400` Invalid request parameters. One or more required fields are missing or malformed. Check the error message for specific field validation failures.
- `409` Seat ID conflict. The requested `seat_id` is already registered by another DSP. Choose a different seat ID. Seat IDs from suspended or revoked DSPs are also unavailable.
- `415` Unsupported media type. The request Content-Type header must be `application/json`.
- `429` Rate limit exceeded. Onboarding is limited to 5 requests per hour per IP address. Wait for the period indicated by the `Retry-After` header.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Inventory

Real-time DOOH inventory discovery with FEIN edge AI signals.

Browse available screens as OpenRTB 2.6 bid request objects, each enriched with live
audience data from on-device computer vision and audio analysis. No other SSP provides
real-time face count, gaze attention, emotion detection, and purchase intent signals
directly in the bid request.

**Three discovery modes:**
- **`GET /adslots`** — Structured filtering by venue type, geography, and FEIN thresholds
- **`POST /discover`** — Semantic search using natural language (powered by pgvector embeddings)
- **`POST /forecast`** — Budget-based delivery estimation using 7-day ClickHouse historical data

#### GET /openrtb/v2/adslots

Browse available inventory with FEIN signals

Returns OpenRTB 2.6 bid request objects for all active screens currently seeking
programmatic demand. Each bid request includes live FEIN (Face/Edge Intelligence
Network) signals in the `ext.trb.fein` extension when available.

**This is the primary inventory discovery endpoint.** Use it to:
- Browse all available screens with real-time audience data
- Filter by geography, venue type, and audience signals
- Get ready-to-use OpenRTB 2.6 bid request objects for your bidder

**FEIN signal filtering:**
- `min_vas=5.0` — Only screens with 5+ Verified Attention Seconds (7-day average)
- `min_attention=60` — Only screens where 60%+ of detected faces are paying attention
- `emotion=happy` — Only screens where the dominant audience emotion is "happy"

Filters are combined with AND logic. Screens that lack FEIN sensors are excluded
when FEIN filters are applied.

**Signal coverage** is reported in the response. Not all screens have FEIN sensors:
- `total_screens` — Total screens matching geographic/venue filters
- `fein_enriched` — How many of those have live or historical audience data
- `after_filters` — How many remain after applying FEIN signal filters

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAdSlots`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `format` | query | `string`: one of `openrtb`, `screens` |  | Response format. Determines the shape of the response body. - `screens` (default) — Returns `{ total, screens, fein_coverage }` with screen summaries and FEIN sensor coverage stats. - `openrtb` — Returns `{ available_slots, bid_requests }` with full OpenRTB 2.6 bid request objects suitable for programmatic bidding. |
| `venue_type` | query | `string` |  | Filter by venue taxonomy. Common values: retail, restaurant, bar, gym, hotel_lobby, transit, office, hospital, salon, gas_station, laundromat, airport, university, coworking. Case-insensitive. |
| `country` | query | `string` |  | ISO 3166-1 alpha-2 country code. Filters screens to the specified country. |
| `state` | query | `string` |  | State or province code (e.g., "CA" for California, "ON" for Ontario). Requires `country` parameter for accurate results. |
| `city` | query | `string` |  | City name filter. Case-insensitive partial match. |
| `limit` | query | `integer` |  | Maximum number of screens to return. Default is 50. Maximum is 200. Results are ordered by FEIN signal quality (live > historical > none), then by VAS score descending. |
| `min_vas` | query | `number` |  | Minimum Verified Attention Seconds (7-day rolling average). VAS measures how many seconds of verified human attention a screen receives per ad play. Higher VAS means more engaged audiences. Typical range: 2.0 to 15.0. |
| `min_attention` | query | `number` |  | Minimum attention score on a 0-100 scale. Attention score measures the percentage of detected faces that are looking at the screen. A score of 60 means 60% of people facing the screen are actively looking at it. |
| `emotion` | query | `string`: one of `happy`, `sad`, `angry`, `surprised`, `neutral`, `fear`, `disgust` |  | Filter by dominant audience emotion detected by on-device face analysis. Returns only screens where the current dominant emotion matches. |
| `cursor` | query | `string` |  | Opaque pagination cursor. Pass the `cursor.next` value from a previous response to fetch the subsequent page. Cursors encode the (updated_at, id) tuple of the last screen returned, so pagination remains stable across concurrent inventory updates. Omit on the first request to start from the beginning. |
| `since` | query | `string` |  | Delta-sync watermark. ISO8601 timestamp; only screens whose `updated_at >= since` are returned. Use this for hourly cron polling — pass the timestamp of the last successful sync to fetch only screens that changed since then. |
| `include_count` | query | `boolean` |  | When `true`, the response includes `total_count` — the total number of inventory rows matching your filters. Use it to plan a batched full sync. `total_count` is also returned automatically on a first-sync page (no `cursor`); pass `include_count=true` to obtain it on a cursored page as well. Defaults to `false`. |
| `source` | query | `string`: one of `owned`, `vistar`, `all` |  | Inventory source partition: - `owned` (default) — Trillboards owned + operated screens only. Byte-for-byte the historical behavior. - `vistar` — curated resold partner (Vistar Media) inventory only. - `all` — owned inventory first, then the curated partner partition. Partner inventory is surfaced only when the server-side `VISTAR_BOOKABLE_DISPLAY` flag is enabled (gated on the buying/fulfillment track shipping, so displayed inventory is always transactable). When it is off, `all` returns owned inventory only and `vistar` returns an empty page (the partner partition is forced empty). Every screen row carries a top-level `source` discriminator (`owned` \| `vistar`), and the response includes `sources_enabled` — the list of active partitions. Pagination cursors are source-scoped; legacy 2-part cursors decode as `owned`, so existing integrations are unaffected. |

Responses:

- `200` Available inventory. Response shape depends on the `format` query parameter: - **Default (`format=screens`):** Returns `{ total, screens, fein_coverage }` with screen summaries and FEIN sensor coverage statistics. - **OpenRTB (`format=openrtb`):** Returns `{ available_slots, bid_requests }` with full OpenRTB 2.6 BidRequest objects extended with `ext.trb.fein` for audience signals. Every screen row carries a top-level `source` discriminator (`owned` | `vistar`), and the response includes `sources_enabled` — the active inventory partitions (e.g. `["owned"]`, or `["owned","vistar"]` when partner display is enabled). See the `source` query parameter.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/discover

Semantic inventory search

Find screens using natural language queries. Uses **pgvector semantic embeddings**
to match your query against scene descriptions generated by on-device vision
language models (VLMs).

**Example queries:**
- "busy coffee shops with young professionals in Manhattan"
- "gym lobbies with TVs near college campuses"
- "high-end restaurants in Beverly Hills with bar seating"
- "transit hubs with high foot traffic during rush hour"

Results are ranked by cosine similarity to your query embedding. Each result
includes screen details and FEIN signals (when available).

Optionally provide a `budget` to receive delivery estimates alongside results.

**Note:** When the semantic search backend (pgvector) is unavailable, the
response is simplified — results are returned without similarity scores and
the `fein` object is omitted from each result. The endpoint still returns
matching screens based on keyword/filter fallback.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `discover`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `budget` | `number` |  | Optional budget in USD. If provided, each result includes a delivery estimate for that screen based on historical data. |
| `limit` | `integer` |  | Maximum number of results to return. Default is 20, maximum is 50. Results are ordered by semantic similarity score descending. |
| `query` | `string` | yes | Natural language description of your desired inventory. Be as specific as possible — include venue type, audience demographics, geography, and any other criteria. The query is embedded using the same model that encodes scene descriptions from on-device VLMs. |

Responses:

- `200` Matching screens ranked by semantic similarity
- `400` Invalid request — the `query` field is required
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/domedia/availability

Quote mapped DOmedia inventory availability

DOmedia OpenDirect callback over the existing inventory and pricing owners.
Requires a platform key owned by data_partner/domedia with inventory:read.
Product IDs are DOmedia asset IDs. UTC flight boundaries are continuous,
start-inclusive and end-exclusive. Price is USD for the available flight.
Assets without positive available capacity are omitted. A missing canonical
price omits price without erasing known availability.
The existing data API limiter allows 60 authenticated requests per minute
per partner; the callback shares that counter and its rate-limit response.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDomediaInventoryAvailability`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `currency` | `string`: one of `USD` |  |  |
| `enddate` | `string` | yes | Must follow startdate. |
| `productids` | array of `string` | yes | Provider asset IDs; repeated IDs are deduplicated. |
| `startdate` | `string` | yes |  |

Responses:

- `200` Available canonical inventory and optional USD flight prices.
- `400` Invalid product IDs, flight boundaries or currency.
- `401` Missing, invalid or revoked platform credential.
- `403` Wrong owner, capability or unsupported credential constraint.
- `429` Shared inventory request limit exceeded (60 per minute).
- `503` Inventory connection disabled or temporarily unavailable.

#### POST /openrtb/v2/forecast

Estimate campaign delivery and spend

Given targeting criteria and a budget, estimates how many impressions you can
expect, your likely win rate, and average CPM. Forecasts are computed from
7 days of historical ClickHouse rollup data from matching screens.

**Use this before creating a campaign** to understand expected delivery and
optimize your budget allocation.

The `confidence` field indicates forecast reliability:
- **high** — 50%+ of matching screens have 7+ days of historical data
- **medium** — Some screens have data but coverage is incomplete
- **low** — Insufficient historical data; estimates are rough projections

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `forecast`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `budget_usd` | `number` | yes | Total campaign budget in USD. Must be greater than 0. The forecast will estimate how many impressions this budget can buy at current market clearing prices. |
| `end_date` | `string` |  | Campaign end date in YYYY-MM-DD format. If omitted, defaults to 7 days after start_date. |
| `pricing_model` | `string`: one of `cpm`, `vas_cpm` |  | Pricing model to use for the forecast. - **cpm** — Standard cost per mille (thousand impressions) - **vas_cpm** — Verified Attention Second CPM (premium pricing based on attention) |
| `start_date` | `string` |  | Campaign start date in YYYY-MM-DD format. If omitted, defaults to tomorrow. |
| `targeting` | `object` |  | Targeting criteria to scope the forecast. If omitted, forecast covers all available inventory. |

Responses:

- `200` Delivery forecast computed successfully. When no screens match the targeting criteria, the response contains only a `message` field (e.g., `{ "message": "No screens match the specified targeting criteria" }`) instead of the full forecast object.
- `400` Invalid request parameters. The `budget_usd` field is required and must be greater than 0. Date formats must be valid YYYY-MM-DD.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Bidding

OpenRTB 2.6 real-time bidding.

Submit standard OpenRTB 2.6 bid responses. Each bid must include an impression ID (`impid`),
a price in CPM, and either inline ad markup (`adm`) or a win notice URL (`nurl`).

Bids are validated against creative blocklists and floor prices, then stored for the
live waterfall auction. The response includes per-bid acceptance or rejection with
specific reasons (below_floor, creative_blocked, missing_adm_and_nurl).

**Win/loss notifications** are sent to your `notification_url` (if configured during
onboarding) as POST requests with the bid ID, win price, and screen details.

#### POST /openrtb/v2/bid

Submit OpenRTB 2.6 bid response

Submit a standard OpenRTB 2.6 bid response. Bids are validated against
creative blocklists and floor prices, then entered into the live waterfall
auction (or scored-only in sandbox mode).

**Each bid requires:**
- `impid` — Impression ID from the `/adslots` bid request
- `price` — CPM bid price in USD (must be > 0)
- `adm` OR `nurl` — Either inline ad markup (VAST XML, HTML) or a win notice URL

Bids missing both `adm` and `nurl` are rejected with reason `missing_adm_and_nurl`.
Bids below the floor price are rejected with reason `below_floor` (the floor price
and your bid price are included in the rejection for transparency).

**Response includes per-bid granularity:** each bid is individually accepted or
rejected, so a single bid response can have both accepted and rejected bids.

**`nurl` macro contract (PR 4):** when your cached bid wins delivery, Trillboards
fires a GET to your `nurl` URL with these macros expanded before the request
leaves our edge:

| Macro | Replaced with |
| --- | --- |
| `${AUCTION_ID}` | request id of the served opportunity |
| `${AUCTION_BID_ID}` | the `bid.id` from your seatbid |
| `${AUCTION_IMP_ID}` | the `bid.impid` |
| `${AUCTION_SEAT_ID}` | your DSP seat |
| `${AUCTION_AD_ID}` | the `bid.adid` |
| `${AUCTION_PRICE}` | the clearing CPM (USD) |
| `${AUCTION_CURRENCY}` | always `USD` today |

The nurl call is fire-and-forget on the server side — a 4xx from your
endpoint never blocks the served VAST, but it WILL surface in our
partner-recon reports as a delivery anomaly.

**Rate limit:** 1,000 requests/minute (production), 100 requests/minute (sandbox).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `acceptBidResponse`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cur` | `string` |  | Bid currency (currently only USD is supported) |
| `id` | `string` | yes | Bid response ID. Must match the bid request ID from `/adslots`. Used for correlation in win/loss notifications. |
| `seatbid` | array of `object` | yes | Array of seat bid objects, one per buyer seat |

Responses:

- `200` Bid processing result. Each bid is individually accepted or rejected. A single response can contain both accepted and rejected bids.
- `400` Invalid bid response. The request body must be a valid OpenRTB 2.6 bid response with at least one seatbid containing at least one bid.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `415` Content-Type must be `application/json`. Ensure your request includes the header `Content-Type: application/json`.
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Creatives

Creative upload with automatic Gemini AI moderation.

Upload creatives via URL reference or presigned S3 upload. Every creative is automatically
processed by the Gemini AI moderation pipeline, which produces:

- **Content classification** — 8 safety flags (alcohol, tobacco, gambling, etc.)
- **Age rating** — G, PG, PG-13, R, NC-17
- **Brand detection** — Identifies known brands in creative content
- **IAB categories** — IAB Content Taxonomy 1.0 codes for programmatic targeting
- **Content analysis** — Subjects, setting, mood, quality, keywords

Moderation completes in approximately 30 seconds. Poll `GET /creatives/{id}` to check
status. Only `approved` creatives can be used in campaigns.

#### GET /openrtb/v2/creatives

List your creatives

Returns all creatives for the authenticated DSP with moderation status, IAB
categories, and pagination. Use the `status` filter to find creatives in a
specific state (e.g., only approved creatives ready for campaigns).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listCreatives`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `string`: one of `pending_moderation`, `approved`, `rejected`, `active`, `deactivated` |  | Filter by creative status. If omitted, returns creatives in all statuses. |
| `limit` | query | `integer` |  | Maximum number of creatives to return per page. Values above 200 are capped to 200 silently (the server will not return an error). |
| `offset` | query | `integer` |  | Number of creatives to skip for pagination |

Responses:

- `200` List of creatives with moderation status
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/creatives

Upload a creative for AI moderation

Upload a creative asset for automatic Gemini AI moderation. Two upload modes
are supported:

**URL mode** — Provide a `creative_url` pointing to your hosted asset. The URL
must be publicly accessible. Trillboards downloads the asset and begins AI
moderation immediately.

```json
{
  "name": "Summer Sale 15s",
  "content_type": "video/mp4",
  "creative_url": "https://cdn.example.com/ads/summer-sale.mp4"
}
```

**Upload mode** — Get a presigned S3 URL for direct upload. Provide a `filename`
instead of `creative_url`. The response includes `upload.upload_url` — PUT your
file there with the specified Content-Type header, then call
`POST /creatives/{id}/confirm` to trigger moderation.

```json
{
  "name": "Summer Sale 15s",
  "content_type": "video/mp4",
  "filename": "summer-sale.mp4"
}
```

**AI moderation** typically completes within 30 seconds and produces:
- Content classification (8 safety flags)
- MPAA-style age rating (G through NC-17)
- Brand recognition
- IAB Content Taxonomy 1.0 category codes
- Content analysis (subjects, setting, mood, quality, keywords)

Poll `GET /creatives/{id}` to check moderation status. Only creatives with
`status: approved` can be used in campaigns.

**Rate limit:** 100 requests/minute.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createCreative`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_link` | `string` |  | Click-through URL for interactive screens. Opened when a viewer taps or interacts with the ad on touch-enabled displays. |
| `content_type` | `string`: one of `video/mp4`, `video/quicktime`, `video/webm`, `image/jpeg`, `image/png`, `image/webp`, `image/gif` | yes | MIME type of the creative asset. Must match the actual file format. Mismatched content types will cause moderation failures. |
| `creative_url` | `string` |  | **URL mode:** Publicly accessible URL of the creative asset. Trillboards downloads the asset and begins moderation immediately. Must be HTTPS. Maximum file size: 100 MB. |
| `description` | `string` |  | Optional description of the creative content |
| `filename` | `string` |  | **Upload mode:** Filename for presigned S3 upload. The response will include a `upload.upload_url` for direct file upload. After uploading, call `POST /creatives/{id}/confirm` to trigger moderation. |
| `name` | `string` | yes | Human-readable creative name for your reference. Appears in reporting dashboards and moderation results. |

Responses:

- `201` Creative created and moderation enqueued. For URL mode, moderation begins immediately. For upload mode, moderation begins after you upload the file and call `POST /creatives/{id}/confirm`.
- `400` Invalid request. Common causes: - Missing required field `name` or `content_type` - Neither `creative_url` nor `filename` provided - Invalid `content_type` value - `creative_url` is not a valid HTTPS URL
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/creatives/{id}

Get creative details with full moderation results

Returns full creative details including AI moderation results. Use this endpoint
to:

- **Poll moderation status** after uploading a creative (check `moderation.status`)
- **Review content classification** flags (alcohol, tobacco, gambling, etc.)
- **Check age rating** (G, PG, PG-13, R, NC-17)
- **View IAB categories** assigned by the AI pipeline
- **See content analysis** (subjects, setting, mood, quality, keywords)
- **Verify brand detection** results

**Moderation timeline:**
1. `status: pending_moderation` — Creative is in the queue (0-10 seconds)
2. `status: pending_moderation` with `moderation.status: processing` — AI is analyzing (10-25 seconds)
3. `status: approved` or `status: rejected` — Moderation complete (~30 seconds total)

**Tip:** Poll every 5 seconds until `moderation.status` is `completed`.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes | Creative ID returned from `POST /creatives` |

Responses:

- `200` Creative details with full moderation results
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `404` Creative not found or does not belong to your DSP
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/creatives/{id}/confirm

Confirm presigned upload and trigger moderation

After uploading a creative via the presigned URL (upload mode), call this endpoint
to confirm the upload and trigger AI moderation.

**This endpoint is only needed for upload mode** (when you provided `filename`
instead of `creative_url` during creation). URL mode creatives are moderated
automatically.

No request body is required. The endpoint verifies the file exists in S3 and
enqueues the AI moderation pipeline.

**Rate limit:** 100 requests/minute.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `confirmCreativeUpload`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes | Creative ID returned from `POST /creatives` |

Responses:

- `200` Upload confirmed and moderation triggered
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `404` Creative not found or does not belong to your DSP
- `422` Upload not found. The file has not been uploaded to the presigned URL yet, or the presigned URL has expired.
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Campaigns

Campaign creation and screen assignment.

Create campaigns by linking approved creatives to targeting criteria. The API returns
matching screens based on your targeting, then you assign the creative to specific
screens. Screens pick up new assignments within their content refresh cycle
(approximately 30-60 seconds).

**Workflow:**
1. `POST /campaigns` — Create campaign with targeting, get matching screens
2. `POST /campaigns/assign` — Assign creative to specific screen IDs
3. Screens begin showing the creative within 30-60 seconds

#### POST /openrtb/v2/campaigns

Create a campaign with targeting

Creates a campaign linking an approved creative to targeting criteria. The API
returns matching screens based on your targeting, so you can choose which
specific screens to assign.

**The creative must have `status: approved`** (passed AI moderation). Campaigns
cannot be created with pending or rejected creatives.

**Workflow:**
1. Create campaign with targeting criteria (this endpoint)
2. Review the `screens` array in the response
3. Call `POST /campaigns/assign` with your selected `screen_ids`

The response includes up to 20 matching screens. If `matching_screens` exceeds
20, use the `/adslots` endpoint with the same targeting filters to browse
the full inventory.

**Rate limit:** 100 requests/minute.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `createCampaign`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `budget_usd` | `number`, nullable |  | Optional campaign budget in USD. Used for pacing and budget alerts but does not hard-stop delivery. Set to null for uncapped campaigns. |
| `creative_id` | `string` | yes | ID of an approved creative (from `POST /creatives`). The creative must have `status: approved` or `status: active`. Pending or rejected creatives will return 422. |
| `name` | `string` | yes | Campaign name for your reference. Appears in reporting dashboards. |
| `targeting` | `object` |  | Targeting criteria for matching screens. All criteria are combined with AND logic. Omitted fields match all values. Compound targeting fields (`daypart`, `geo_lat`/`geo_lng`/`geo_radius_km`, `audience_segment_ids`, `blocked_iab_categories`, `blocked_advertiser_domains`, `frequency_cap_per_device_per_day`, `budget_pacing`) are enforced server-side at waterfall-mediation time — bids on screens that fail any filter are dropped before reaching delivery. |

Responses:

- `201` Campaign created with matching screen list
- `400` Invalid request parameters
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `404` Creative not found
- `422` Creative is not approved. Only creatives that have passed AI moderation can be used in campaigns. Check `GET /creatives/{id}` for moderation status.
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/campaigns/assign

Place creative on specific screens

Assigns an approved creative to specific screens. Creates screen allocations
that make the ad visible in each screen's content playlist.

**Screens pick up new assignments within 30-60 seconds** via their content
refresh cycle. No manual intervention is needed on the screen side.

**Scheduling:** Optionally provide date and time ranges to control when the
creative is shown. If omitted, the creative runs indefinitely during all hours.

**Limits:** Maximum 100 screen IDs per call. For larger deployments, make
multiple calls. Screen IDs come from the `/adslots` or `/campaigns` response.

**Rate limit:** 100 requests/minute.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `assignToScreens`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `creative_id` | `string` | yes | ID of an approved creative to assign |
| `end_date` | `string` |  | Campaign end date (YYYY-MM-DD). If omitted, runs indefinitely until manually stopped. |
| `end_time` | `string` |  | Daily end time in 24-hour format (HH:MM). The creative will only be shown before this time each day. Default is "23:59". |
| `screen_ids` | array of `string` | yes | Array of screen IDs to assign the creative to. Minimum 1, maximum 100 per request. Get screen IDs from `/adslots`, `/campaigns`, or `/discover`. |
| `start_date` | `string` |  | Campaign start date (YYYY-MM-DD). If omitted, starts immediately. |
| `start_time` | `string` |  | Daily start time in 24-hour format (HH:MM). The creative will only be shown after this time each day. Default is "00:00" (midnight). |

Responses:

- `201` Screens assigned successfully. Allocations are created immediately and screens will begin showing the creative within 30-60 seconds.
- `400` Invalid request parameters
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `422` Creative not approved or not found
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Deals

Private marketplace (PMP) and preferred deal management.

Browse active deals available to your DSP seat, view deal details, and propose new
deals with custom floor CPMs, impression caps, and venue/geography targeting.

**Deal types:**
- **PMP** — Private marketplace with floor price, open to invited buyers
- **Preferred** — Preferred deal with fixed price, priority over open auction
- **Guaranteed** — Guaranteed delivery with committed volume

Proposed deals require admin approval and transition from `pending` to `active` status.

#### GET /openrtb/v2/deals

List active deals for your DSP

Returns all active deals available to the authenticated DSP seat. Deals are
filtered by your seat ID — you only see deals where your DSP has been
explicitly invited or deals that are open to all registered DSPs.

**Deal types:**
- **PMP** — Private marketplace deal with floor price
- **Preferred** — Preferred deal with fixed pricing and priority
- **Guaranteed** — Guaranteed delivery with committed impression volume

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listDeals`

Responses:

- `200` List of active deals
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/deals/propose

Propose a new deal

Propose a new PMP, preferred, or guaranteed deal. Proposed deals are created in
`pending` status and require admin approval before becoming active.

**Required:** `deal_id` (your unique identifier) and `floor_cpm` (minimum CPM > 0).

**Deal lifecycle:** pending -> active -> (paused | expired)

You will receive a webhook notification (if configured) when the deal status changes.

**Rate limit:** 100 requests/minute.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `proposeDeal`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deal_id` | `string` | yes | Your unique deal identifier. This becomes the `dealid` field in OpenRTB bid requests. Use a descriptive, URL-safe string. |
| `deal_type` | `string`: one of `pmp`, `preferred`, `guaranteed` |  | Type of deal. Defaults to "pmp" if not specified. - `pmp` — Private marketplace with floor price - `preferred` — Fixed price, priority over open auction - `guaranteed` — Committed volume at fixed price |
| `eligible_countries` | array of `string` |  | ISO 3166-1 alpha-2 country codes this deal applies to. Null means all countries. |
| `eligible_venue_types` | array of `string` |  | Venue types this deal applies to. Null means all venue types. |
| `end_date` | `string`, nullable |  | Deal end date (YYYY-MM-DD). Null for no expiration. |
| `floor_cpm` | `number` | yes | Minimum CPM price in USD. All bids under this deal must meet or exceed this floor price. |
| `impression_cap` | `integer`, nullable |  | Maximum impressions for this deal. Set to null for uncapped. Guaranteed deals should always have an impression cap. |
| `start_date` | `string` |  | Deal start date (YYYY-MM-DD). Defaults to today. |

Responses:

- `201` Deal proposed successfully. The deal is created in `pending` status and requires admin approval before becoming active.
- `400` Invalid request parameters
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `409` Deal ID already exists
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/deals/{dealId}

Get deal details

Returns full details for a specific deal, including impression delivery
progress, eligible venues and countries, and pricing terms.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDeal`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `dealId` | path | `string` | yes | Deal identifier |

Responses:

- `200` Deal details
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `404` Deal not found or not available to your DSP seat
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Reporting

ClickHouse-backed analytics and reporting.

All reporting endpoints query ClickHouse materialized views for sub-second response
times across billions of impression events. Three report types are available:

- **Summary** — Aggregated totals: impressions, unique screens, CPM, fill rate
- **Per-screen** — Breakdown by screen: impressions, CPM, fills, hours active
- **Time-series** — Chronological data points at hourly or daily granularity

Reports cover a configurable lookback period (default 7 days, maximum 90 days).

#### GET /openrtb/v2/account

Get DSP account profile (commercial terms + deals)

Returns the authenticated DSP's commercial profile — display/legal name,
billing email, payment terms, currency, tax id, and billing address — plus
the deals visible to the caller's seat.

Commercial terms are sourced from the canonical billing-entity record
(`dsp_billing_entities` + `dsp_ssp_contacts`), falling back to the DSP
registration's company name + billing email for newly-onboarded seats that
do not yet have a billing entity.

**Scope:** requires `stats:read`. The `deals` array (with targeting such as
`eligible_screens`) is included ONLY when the key also holds `deals:read`;
otherwise `deals` is empty and `deal_count` is 0.

The internal Stripe customer id is NEVER returned.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAccount`

Responses:

- `200` DSP account profile
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/reports/creatives

Per-creative rollup for the authenticated DSP seat

Returns a per-creative aggregate of delivery for the authenticated
DSP seat over the requested lookback window. Sources directly from
ClickHouse (`mv_creative_screen_daily`); when CH is unavailable the
endpoint returns `data: []` (200), not a 503 — same posture as the
sibling earner content-performance endpoint. The legacy PostgreSQL
mirror `openrtb_creative_screen_daily` was retired in CUT-11
(2026-05). Results are sorted by `plays` descending.

The `source_provider` filter is applied implicitly: each DSP seat
sees rows where `source_provider = inbound_dsp_<seat>` — the
canonical mapping established by the inbound bid cache and the
waterfall mediator.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getReportCreatives`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Number of days to look back (default 7, maximum 90) |
| `limit` | query | `integer` |  | Maximum number of creatives to return (default 50, maximum 200) |

Responses:

- `200` Per-creative aggregate for the authenticated DSP seat
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/reports/screens

Per-screen performance breakdown

Returns canonical billable completed impressions broken down by screen
for the authenticated DSP seat. Useful for identifying delivered screens
and verifying campaign execution.

**Data source:** ClickHouse `mv_completed_impressions_hourly`, implicitly
filtered to `source_provider = inbound_dsp_<seat>`. Results are sorted by
impressions descending. A ClickHouse outage returns `data: []` with `200`;
no global PostgreSQL fallback is used.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getReportScreens`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Number of days to look back (default 7, maximum 90) |
| `limit` | query | `integer` |  | Maximum number of screens to return (default 50, maximum 200) |

Responses:

- `200` Per-screen performance data
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/reports/summary

Aggregated performance summary

Returns billable completed-impression totals for the authenticated DSP seat.
Seat isolation is applied implicitly with the canonical
`source_provider = inbound_dsp_<seat>` mapping.

**Data source:** the ClickHouse `mv_completed_impressions_hourly` rollup,
derived from canonical `completed_impressions`. Maximum lookback: 90 days.
If ClickHouse is unavailable, the endpoint returns a zero-valued `200`
rather than falling back to a global, non-seat-scoped PostgreSQL rollup.

`filled`, `no_fill`, and `fill_rate` are compatibility fields on this
completion-ledger report: every returned impression is filled/completed,
`no_fill` is `0`, and `fill_rate` is `1` when delivery exists (otherwise
`0`). Use `GET /stats` for seat-level bid and win metrics.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getReportSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Number of days to look back (default 7, maximum 90) |

Responses:

- `200` Performance summary for the specified period
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/reports/timeseries

Time-series performance data

Returns canonical billable completed-impression data points for the
authenticated DSP seat at hourly or daily granularity.

**Data source:** ClickHouse `mv_completed_impressions_hourly`, implicitly
filtered to `source_provider = inbound_dsp_<seat>`. Each point includes
completed impressions, revenue-weighted average CPM, and active screens.
A ClickHouse outage returns `data: []` with `200`.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getReportTimeseries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Number of days to look back (default 7, maximum 90) |
| `granularity` | query | `string`: one of `hourly`, `daily` |  | Time bucket granularity. Use "hourly" for detailed intraday data or "daily" for longer-range trends (up to 90 days). |

Responses:

- `200` Chronological performance data
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/stats

Get 24-hour performance stats

Returns aggregated performance metrics for the authenticated DSP over the
last 24 hours.

**Metrics returned:**
- Total bids submitted
- Total auction wins
- Average CPM (clearing price)
- Total revenue in USD
- Average bid-to-win latency in milliseconds

## A metric can be `null`, and `null` is not zero

Every metric is nullable. `null` means **not measured** — it is never a
stand-in for zero, and the response never substitutes a placeholder value
for a missing one. Branch on the top-level `measured` boolean before you
read a number, and read `stats.coverage` for the reason when it is
`false`:

| `stats.coverage` | Meaning |
| --- | --- |
| `measured` | Real figures. `matched_auctions` is the row count behind them. |
| `seat_absent_from_auction_metadata` | No auction on our side is attributed to this seat in the window, so there is nothing to total. |
| `clickhouse_unavailable` | We could not read the warehouse. This is our outage, not a statement about your bidding. |

This replaces earlier behaviour in which an empty or failed read returned
five zeros with a `200`, which read as a measurement ("you bid zero
times") when it was in fact the absence of one.

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDSPStats`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `seat` | query | `string` |  | DSP seat identifier. If omitted, auto-filled from the authenticated API key. Only specify this if your API key has access to multiple seats. |

Responses:

- `200` 24-hour performance metrics
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Screens

Deep screen intelligence — detail profile, capacity forecasting, and audience profiles.

These endpoints support direct IO booking, pre-auction targeting, and pre-bid
decisioning. Unlike `/adslots` (which returns OpenRTB bid requests), these surfaces
are purpose-built for structured screen-by-screen queries.

- **`GET /screens/:id`** — Full profile: physical size, spot config, venue intelligence,
  VAS scoring, performance rollups, reliability, device capabilities, brand safety.
- **`POST /screens/availability`** — Bulk capacity forecast + booked impressions
  for up to 50 screens and an arbitrary date range. Returns deliverable capacity,
  booked impressions, and remaining availability per screen.
- **`POST /screens/audience`** — Bulk audience profiles for up to 50 screens,
  combining live Redis signals (data_quality=live) with VAS rolling averages
  (data_quality=historical).

#### POST /openrtb/v2/screens/audience

Bulk audience profiles for up to 50 screens

Returns structured audience intelligence for the specified screens. Combines
three data sources with graceful degradation — any subset may be null without
failing the overall request:

1. **Redis live signals** — fresh face count, attention, emotion, purchase intent,
   and ad receptivity (10-30 s refresh).
2. **VAS rolling averages** — Verified Attention Seconds, CPM tier, multiplier.
3. **PostgreSQL venue enrichment** — neighborhood classification, Places rating,
   amenity density, foot-traffic level.

**Per-screen shape:**
- `data_quality`: `live` when Redis has fresh data, `historical` when only
  VAS is available, `none` when no audience data exists for the screen.
- `signals_age_ms`: milliseconds since the last Redis update (null for non-live).
- `demographics`, `behavior`, `venue` — filtered by `signal_types` if provided.
- `vas` is always included (core DSP signal).
- `patterns` is returned only when `include_patterns=true`. Currently reserved
  for a future ClickHouse-backed hourly aggregation; returns `null` today.

**Limits:** Maximum 50 screen IDs per request.

**Rate limit category:** Inventory (200 req/min production).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenAudience`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `include_live` | `boolean` |  | Include fresh Redis signals. Defaults to `true`. Set `false` to skip the Redis query and force historical-only output. |
| `include_patterns` | `boolean` |  | Request the `patterns` object (hourly breakdown). Reserved for a future ClickHouse-backed aggregation; returns `null` until shipped. |
| `screen_ids` | array of `string` | yes | Array of screen IDs (1-50 items). |
| `signal_types` | array of `string`: one of `demographics`, `behavior`, `venue` |  | Optional filter for returned sections. When omitted, all sections (`demographics`, `behavior`, `venue`) are included. `vas` is always returned. |

Responses:

- `200` Per-screen audience profiles.
- `400` Invalid request. Common causes: missing `screen_ids`, empty array, more than 50 IDs, or invalid `signal_types` value.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/screens/availability

Bulk capacity and booking query

Returns per-screen deliverable capacity, booked impressions, and remaining
availability for a given date range. Use this to confirm there is enough
inventory to satisfy your flight before you bid or book.

**What you get per screen:**
- **Capacity** — Total impressions deliverable in the window, plus spots, hours,
  and a forecast confidence (0-1).
- **Booked** — Impressions already committed to active/scheduled placements.
- **Available** — Remaining impressions and utilization percentage.
- **Reliability** — Whether the screen is currently online and its last heartbeat.
- **Daily breakdown** (optional) — Per-day capacity/booked/available when
  `granularity=daily` is provided.

**Caching:** 5-minute in-memory cache keyed by the hash of the screen ID list
and date range. Serving from cache returns `X-Cache: HIT`.

**Limits:** Maximum 50 screen IDs per request. Returns a 503 if the forecast
service is unavailable.

**Rate limit category:** Inventory (200 req/min production).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenAvailability`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `end_date` | `string` | yes | Flight end date (YYYY-MM-DD). Inclusive. |
| `end_time` | `string` |  | Optional daily end time (HH:MM, 24-hour). Defaults to `23:59`. |
| `granularity` | `string`: one of `flight`, `daily` |  | Response granularity: - `flight` — total capacity across the window (default) - `daily` — per-day breakdown in `daily_breakdown` |
| `screen_ids` | array of `string` | yes | Array of screen IDs to check availability for (1-50 items). |
| `start_date` | `string` | yes | Flight start date (YYYY-MM-DD). |
| `start_time` | `string` |  | Optional daily start time (HH:MM, 24-hour). Defaults to `00:00`. Used when you only want to measure capacity within a daypart. |

Responses:

- `200` Per-screen availability breakdown with summary totals.
- `400` Invalid request. Common causes: missing `screen_ids`/`start_date`/`end_date`, empty `screen_ids` array, or more than 50 screen IDs.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.
- `503` Forecast service unavailable. The downstream impression-forecast engine is offline; retry after a short delay.

#### GET /openrtb/v2/screens/{id}

Comprehensive screen profile

Returns a full profile for a single screen — physical size, spot config,
venue intelligence, live + historical audience data, VAS scoring, 30-day
performance metrics, reliability, device capabilities, programmatic settings,
and brand-safety flags.

**Data sources (in order of latency):**
1. **Redis** — live audience signals (~10 s refresh)
2. **PostgreSQL** — screen metadata, venue enrichment, placements
3. **ClickHouse** — performance rollups (30-day fill, completion, funnel)

**Caching:** 2-minute in-memory cache keyed by `screen_id`. The `X-Cache`
response header is `HIT` when served from cache, `MISS` otherwise.

**Rate limit category:** Inventory (200 req/min production).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenDetail`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes | Screen identifier — the `mongo_earnerscreen_id` (24-character MongoDB ObjectId) returned by `/adslots` or `/inventory/v1/screens`. Must be at least 10 characters. |

Responses:

- `200` Full screen profile with all enrichment sections populated.
- `400` Invalid screen ID. The path parameter must be at least 10 characters.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `404` Screen not found. The screen either does not exist or has been soft-deleted.
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Market Intelligence

CPM benchmarks, demand curves, and segment premium analysis for pre-bid pricing.

Data is sourced from PG rollup tables (refreshed every 5 minutes from ClickHouse
materialized views). Queries return in <100 ms and cover configurable lookback
windows up to 365 days.

- **`GET /market/cpm-benchmarks`** — p25/median/p75/avg CPM percentiles by venue
  type, country, state, and daypart. Includes a market overview with total
  SSP-enabled screens, average fill rate, and average completion rate.
- **`GET /market/demand-analysis`** — Top advertiser categories by bid volume,
  daypart demand curves (avg CPM + fill rate by morning/afternoon/evening/late_night),
  and segment CPM premium lift over baseline.

#### GET /openrtb/v2/market/cpm-benchmarks

CPM percentile benchmarks by venue and geography

Returns p25/median/p75/avg CPM percentiles per venue type, optionally
filtered by country, state, or daypart. Also returns a market overview with
total SSP-enabled screens, average fill rate, and average completion rate.

**Data source:** `bid_intelligence` and `openrtb_vast_screen_hourly` PG rollup
tables. Cached in memory for 10 minutes per query.

**Rate limit category:** Reporting (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCpmBenchmarks`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `venue_type` | query | `string` |  | Filter to a single venue category (e.g., `coffee_shop`, `gym`). |
| `country` | query | `string` |  | ISO 3166-1 alpha-2 country code. |
| `state` | query | `string` |  | State or province filter. |
| `granularity` | query | `string`: one of `none`, `daypart`, `daily` |  | Row grouping: - `none` — single row per venue type (default) - `daypart` — one row per venue type × daypart - `daily` — one row per venue type × day |
| `days` | query | `integer` |  | Lookback window in days (default 30, maximum 365). |

Responses:

- `200` CPM benchmarks and market overview.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### GET /openrtb/v2/market/demand-analysis

Top categories, daypart curves, segment premiums

Returns demand-side analytics: top advertiser categories by bid volume, daypart
demand curves (avg CPM + fill rate by morning/afternoon/evening/late_night),
and segment CPM premium lift (if the segment correlation table is populated).

**Data source:** `bid_intelligence` and `openrtb_vast_screen_hourly` PG rollup
tables. Segment data is sourced from `segment_cpm_correlation` when available
(graceful degradation returns an empty `segment_premiums` array otherwise).

**Rate limit category:** Reporting (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDemandAnalysis`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Lookback window in days (default 30, maximum 365). |
| `limit` | query | `integer` |  | Max top categories to return (default 10, maximum 100). |

Responses:

- `200` Demand analysis breakdown.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Analytics

VAST event funnel — from ad requests to completed plays.

Returns a request → bid_received → render_started → play_completed funnel with
bid rate, fill rate, and start-to-complete rate. Supports per-screen breakdown
(top 50) and daily/hourly/flight timeseries.

**Data source:** `openrtb_vast_screen_hourly` PG rollup table (OLTP-safe, refreshed
every 5 minutes from ClickHouse). Cached in memory for 5 minutes per query
fingerprint.

#### GET /openrtb/v2/analytics/funnel

VAST event funnel (requests → renders → completions)

Returns a VAST event funnel: `requests → bids_received → renders_started →
plays_completed`, plus computed rates (`bid_rate`, `fill_rate`,
`start_to_complete`), a top-50 per-screen breakdown, and optional timeseries
at daily/hourly granularity.

**Data source:** `openrtb_vast_screen_hourly` PG rollup table (OLTP-safe,
refreshed every 5 minutes from ClickHouse). In-memory cache: 5 minutes per
query fingerprint.

**Funnel column mapping:**
- `request_count` → `requests`
- `response_count` → `bids_received`
- `filled_count` → `renders_started` (ad began playing)
- `completed_count` → `plays_completed` (ad played to full completion)

**Rate limit category:** Reporting (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVastFunnel`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` | yes | Flight start date (YYYY-MM-DD). |
| `end_date` | query | `string` | yes | Flight end date (YYYY-MM-DD). Inclusive. |
| `screen_ids` | query | `string` |  | Comma-separated list of screen IDs to restrict the funnel to. |
| `creative_id` | query | `string` |  | Reserved for future use (creative-level funnel filter). |
| `granularity` | query | `string`: one of `flight`, `daily`, `hourly` |  | Timeseries granularity: - `flight` — no timeseries, only aggregate + per-screen (default) - `daily` — one bucket per day - `hourly` — one bucket per hour |

Responses:

- `200` Funnel, per-screen breakdown, and optional timeseries.
- `400` Invalid request. Common causes: missing `start_date`/`end_date`, invalid date format (must be YYYY-MM-DD), or `start_date` after `end_date`.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### Proof of Play

Verified ad play records for reconciliation and billing audit.

Returns rows from the `completed_impressions` table (partitioned monthly on
`completed_at`) with verification tier mapping:

- **`signed`** — Ed25519 signed, screen-side proof-of-play confirmation
- **`event_confirmed`** — VAST `complete` event fired by the IMA SDK
- **`impression_callback`** — Impression pixel fired by the screen

All queries must include `start_date` and `end_date` (required for partition
pruning). Responses include per-tier counts, GIVT pass rate, and paginated
proof records.

#### GET /openrtb/v2/proof-of-play

Verified ad play records with tier breakdown

Returns verified play records from `completed_impressions` so DSP buyers can
reconcile billing against actual screen deliveries. Each record is tagged with
a `verification_tier`:

- **`signed`** — Ed25519 signed proof-of-play from the screen (highest trust)
- **`event_confirmed`** — VAST `complete` event fired by the IMA SDK
- **`partner_reported`** — Partner SDK lifecycle completion report

Impression callbacks are pixel fires, not completed plays, and are not
returned by this DSP-facing proof surface.

**Required:** `start_date` and `end_date`. These are always included in the
query WHERE clause for partition pruning — the `completed_impressions` table
is partitioned monthly, so omitting them would trigger a full scan and time out.

**Rate limit category:** Reporting (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getProofOfPlay`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` | yes | ISO 8601 timestamp — lower bound on `completed_at` (inclusive). |
| `end_date` | query | `string` | yes | ISO 8601 timestamp — upper bound on `completed_at` (exclusive). |
| `screen_id` | query | `string` |  | Filter to a single screen (mongo_earnerscreen_id). |
| `creative_id` | query | `string` |  | Filter to a single creative. |
| `campaign_id` | query | `string` |  | Filter to a single campaign (maps to `ad_id`). |
| `verification_tier` | query | `string`: one of `signed`, `event_confirmed`, `partner_reported` |  | Restrict to a single verification tier. |
| `limit` | query | `integer` |  | Rows to return (default 50, maximum 200). |
| `offset` | query | `integer` |  | Pagination offset (default 0). |

Responses:

- `200` Proof records with tier-summary statistics.
- `400` Invalid request. Common causes: missing `start_date`/`end_date`, invalid ISO date strings, `start_date` after `end_date`, or invalid `verification_tier` value.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

### DSP Webhooks

Managed webhook subscription system for real-time event notifications.

Register HTTPS endpoints with a filtered list of event types. Trillboards
dispatches signed HTTPS POSTs as events occur across the ad lifecycle (impression,
campaign, screen). Each webhook receives a unique `whsec_...` secret
at creation — **shown exactly once** — that must be used to verify the
`X-Trillboards-Signature` header on delivery.

- **`POST /webhooks`** — Register a webhook subscription (URL + events).
- **`GET /webhooks`** — List all webhooks for your DSP seat (without secrets).
- **`GET /webhooks/deliveries`** — Self-serve delivery log (recent attempts + failures).
- **`DELETE /webhooks/:id`** — Remove a webhook subscription. Ownership verified.

**Limits:** Max 10 active webhooks per DSP seat. URL must use HTTPS. Only events
from the supported event list are accepted.

#### GET /openrtb/v2/webhooks

List webhook subscriptions for this DSP seat

Returns all webhooks for your DSP seat, ordered by creation time descending.
**Secrets are never returned** by this endpoint — they are only shown once at
creation time.

Each entry includes delivery stats (`success_count`, `failure_count`,
`last_triggered_at`, `last_error`) so you can monitor webhook health.

**Rate limit category:** Campaigns (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listWebhooks`

Responses:

- `200` List of webhooks for the current DSP seat.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### POST /openrtb/v2/webhooks

Register a webhook subscription

Registers a new webhook for your DSP seat. Returns a unique `whsec_...`
secret **exactly once** — store it immediately. The secret is used to
verify the `X-Trillboards-Signature` header on every delivery.

**URL must be HTTPS.** HTTP URLs, missing hostnames, and non-URL strings are
rejected.

**Events must be from the supported list.** See the `events` enum below; any
unknown event name is rejected with a 400 listing the allowed values.

**Limits:** Maximum 10 active webhooks per DSP seat. Exceeding this returns
429 with the current count.

**Rate limit category:** Campaigns (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `registerWebhook`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  | Optional human-readable note to identify the webhook in the list view (e.g., "Staging environment", "Billing pipeline"). |
| `events` | array of `WebhookEvent` | yes | Non-empty list of event types to subscribe to. All entries must match one of the supported event names. |
| `url` | `string` | yes | HTTPS endpoint that will receive webhook deliveries. |

Responses:

- `201` Webhook registered successfully. The `secret` field is returned **exactly once** — store it in a secrets manager now.
- `400` Invalid request. Common causes: missing/malformed `url`, non-HTTPS URL, empty `events` array, or unknown event name.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Quota exceeded. Either the campaigns rate limit was hit, or the DSP seat already has 10 active webhooks.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.
- `503` Database write unavailable.

#### GET /openrtb/v2/webhooks/deliveries

Self-serve webhook delivery log

Returns recent delivery attempts for this DSP seat's webhooks. Each delivery
record captures the event name, target URL, HTTP response code, response
body sample, signature, and timestamp. Use this to debug failing deliveries
without having to instrument your own receiver.

**Note:** The delivery log table is being finalized in a follow-up release.
Until it ships, this endpoint returns an empty `data` array and a `note`
field indicating the stub state. Schema is stable — consumers can integrate
now and will begin receiving records when the backing table is live.

**Rate limit category:** Campaigns (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listWebhookDeliveries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | query | `string` |  | Filter to a single webhook ID. |
| `status` | query | `string`: one of `failed`, `success`, `all` |  | Delivery status filter. |
| `limit` | query | `integer` |  | Max records to return (default 20, maximum 100). |
| `offset` | query | `integer` |  | Pagination offset. |

Responses:

- `200` Delivery log entries. Returns an empty `data` array and a `note` field until the backing table is finalized in a follow-up release.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.

#### DELETE /openrtb/v2/webhooks/{id}

Remove a webhook subscription

Deletes a webhook subscription. Ownership is verified against the
authenticated DSP seat — only the seat that created the webhook can delete
it. Returns 404 if the webhook does not exist.

**Rate limit category:** Campaigns (100 req/min).

- Auth: `BearerAuth` HTTP bearer or `ApiKeyAuth` header `x-api-key`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `deleteWebhook`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes | Webhook ID to delete. |

Responses:

- `200` Webhook deleted.
- `400` Missing webhook ID.
- `401` Missing or invalid API key. Ensure you are including either the `x-api-key` header or `Authorization: Bearer` header with a valid centralized platform API key. **Common causes:** - Missing authentication header entirely - API key has been revoked or rotated - The key has not been linked to a DSP seat yet (call `POST /openrtb/v2/onboard` once with the same key) - Typo in the API key value
- `403` Webhook not owned by this DSP seat.
- `404` Webhook not found.
- `429` Rate limit exceeded. Wait for the period indicated by the `Retry-After` header before retrying. Implement exponential backoff for production reliability.
- `500` Internal server error. An unexpected error occurred on the Trillboards side. If this persists, contact developers@trillboards.com with the timestamp and any request details.
- `503` Database write unavailable.

### Programmatic Bidding

Core OpenRTB bidding and win-notification flows

#### GET /openrtb/v1/adpod/{token}

Resolve an ad-pod fulfilment token

Ad pods (multiple back-to-back ad slots in one break) are fulfilled
via a signed token referencing a pre-built waterfall. Screens redeem
the token here to receive the full VAST document with multiple
`<Ad>` elements in serving order. Single-use, expires after 60
seconds.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `resolveAdPod`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | `string` | yes |  |

Responses:

- `200` Ad-pod VAST 4.2 XML document
- `404` Token expired, already redeemed, or never issued

#### POST /openrtb/v1/bid

Process OpenRTB bid request

Accepts an OpenRTB 2.6 bid request and returns matching bid responses.
Supports DOOH (via `dooh` object) and web (via `site`/`app` objects).
Maximum 10 impressions per request. Returns 204 No Content when no matching ads are available.

- Auth: `exchangeKeyAuth` header `X-API-Key` or `exchangeBearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `processBidRequest`

Request body (`application/json`, required): `BidRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `app` | `object` |  | App placement context |
| `device` | `object` |  |  |
| `dooh` | `object` |  | DOOH-specific placement context (OpenRTB 2.6) |
| `id` | `string` | yes | Unique bid request ID |
| `imp` | array of `object` | yes |  |
| `site` | `object` |  | Web placement context |
| `tmax` | `integer` |  | Maximum response time in ms |

Responses:

- `200` Bid response with matching ads
- `204` No bid -- no matching ads available
- `400` Invalid bid request

#### GET /openrtb/v1/ima-player/{screenId}

HTML5 IMA SDK player harness for a screen

Returns an HTML page that bootstraps Google's IMA SDK against
Trillboards' VAST endpoint for the given screen. Used by partners
whose displays embed a browser-based player. The page is
responsive and adapts to the partner's screen aspect ratio.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getImaPlayerHarness`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` IMA player HTML

#### GET /openrtb/v1/inline/{token}

Resolve an inline-ad fulfilment token

Inline ad delivery uses a short-lived signed token to avoid leaking
creative URLs to off-network actors. Screens redeem the token at this
endpoint to fetch the inline VAST document; the token is single-use
and expires after 60 seconds.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `resolveInlineAdToken`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | `string` | yes | Signed, single-use, time-limited token. |

Responses:

- `200` Inline VAST 4.2 XML document
- `404` Token expired, already redeemed, or never issued

#### GET /openrtb/v1/vast

Generate a VAST document for an ad

Returns an inline VAST document for one advertisement.

## Two different documents come out of this path

Which one you get is decided by whether you send `screenId`, and they
are not the same VAST version:

- **`screenId` supplied, and a servable owner placement exists** —
  a **VAST 3.0** owner-direct document. Its tracking URLs are signed
  and carry owner-billing parameters, and it has no
  `ViewableImpression` and no `AdVerifications`.
- **No `screenId`** — a **VAST 4.2** programmatic document with
  `ViewableImpression` and an OMSDK `AdVerifications` extension.

So a client that hardcodes an expectation of 4.2 will break the moment
it starts sending `screenId`. Parse the version rather than assuming
it, and prefer following the wrapper as any compliant player does.

## There is no empty-VAST no-fill on this path

A request that cannot be filled is a `404` with a plain-text body, not
a `200` carrying an empty `<VAST/>`. That differs from
`GET /openrtb/v1/vast/{partnerKey}/{screenId}`, which always answers
`200` and uses an empty document to signal no-fill. A player wired to
this endpoint must treat a non-200 as no-fill.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `generateVast`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | query | `string` |  | Screen identifier |
| `adid` | query | `string` | yes | Advertisement ID |
| `impid` | query | `string` |  | Impression ID for tracking correlation |
| `w` | query | `integer` |  | Video width in pixels |
| `h` | query | `integer` |  | Video height in pixels |

Responses:

- `200` An inline VAST document. See the two branches above — the version depends on whether you sent `screenId`.
- `404` No servable video ad for this request. The body is the bare string `Video ad not found`, not a JSON error envelope — this operation predates the shared error model and has not been migrated. Do not `JSON.parse` it. The media type is `text/html; charset=utf-8`: the handler passes a plain string to `res.send` without setting a content type, which is what Express defaults to. It is not HTML, and it is not `text/plain`. `GET /openrtb/v1/win` returns its string bodies the same way.

#### GET /openrtb/v1/vast-wrap/{provider}/{screenId}

Generate a partner-wrapped VAST response

Returns a VAST 4.2 document where the inner `<Wrapper>` element points
at the demand-partner's VAST endpoint, while the outer document carries
Trillboards' tracking macros (impression pixel, proof-of-play). Used
when bidding partners require redirect-style ad serving.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `getVastWrapperByProvider`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `provider` | path | `string` | yes | Opaque provider alias issued at onboarding. Treat as a secret. |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` VAST 4.2 wrapper XML
- `404` No bid for this screen + provider

#### GET /openrtb/v1/vast/direct/{screenId}/{adid}

Serve a fixed first-party VAST tag for one booked ad and screen

Stable URL used by managed-service publishers that configure a VAST tag
directly in their own player. The URL itself remains fixed, while every
request receives a fresh impression identity and signed lifecycle tracking
beacons. The endpoint fails closed unless the advertisement has a currently
servable placement on the requested screen.

The response is a VAST 3.0 InLine document containing a progressive H.264
MP4 MediaFile plus signed impression, quartile, completion, error, and
proof-of-play reporting URLs. Responses are never cached.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `getFixedDirectVastTag`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Trillboards Mongo-format screen ID bound to the booking |
| `adid` | path | `string` | yes | Trillboards Mongo-format advertisement ID bound to the booking |
| `deviceId` | query | `string` |  | Optional device fingerprint included in signed playback telemetry |
| `w` | query | `integer` |  | Declared MediaFile width in pixels |
| `h` | query | `integer` |  | Declared MediaFile height in pixels |

Responses:

- `200` Fresh VAST 3.0 InLine document for the booked ad and screen
- `404` Advertisement is missing, has no playable MP4, or is not currently servable on the screen
- `429` Request rate limit exceeded

#### GET /openrtb/v1/vast/{partnerKey}/{screenId}

Generate VAST response on a partner-variant alias

Path-parameterised entry-point used during partner onboarding to
keep partner-specific configuration (creative whitelist, eCPM
floor, deal IDs, custom macros) indexed by `partnerKey`. The
`partnerKey` is issued at onboarding and MUST match the variant
assigned to the caller's API key, or the request is rejected.

The returned document is the same VAST 4.2 shape produced by
`GET /openrtb/v1/vast`; the only difference is that partner-specific
configuration is selected by `partnerKey` rather than by API-key
lookup, which lets a single partner test multiple configurations
side-by-side from one credential.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `getVastByPartnerVariant`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `partnerKey` | path | `string` | yes | Opaque partner-variant alias issued at onboarding. Treat as a secret — do not guess values. |
| `screenId` | path | `string` | yes |  |
| `adid` | query | `string` |  | Optional advertisement ID override. Defaults to waterfall winner. |
| `w` | query | `integer` |  |  |
| `h` | query | `integer` |  |  |

Responses:

- `200` A VAST document — **or an empty one, which is how no-fill is signalled on this path.** ## This endpoint essentially always returns 200 No-fill, a partner timeout, a partner 5xx, an unservable screen and an internal error all return `200` with the body `<?xml version="1.0" encoding="UTF-8"?><VAST version="3.0"/>`. That is deliberate: the caller is a player, and a non-200 renders as a broken ad slot rather than as an empty break. The consequence is that **you must inspect the document to know whether you were filled** — a status check tells you nothing. Look for an `<Ad>` element; an empty `<VAST/>` has none. The only non-200 is a `404` for an unrecognised `partnerKey`. ## The document is the partner's, not ours On a fill this is the winning bidder's own `adm`, passed through a creative filter and then rewritten to inject our tracking pixels. We do not author it, so **its VAST version is whatever the partner sent** — do not rely on 4.2, and follow wrappers as any compliant player does. Sent with `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate` on every response, filled or not, so a no-fill is never cached over a later fill.
- `401` Partner-variant alias does not match the caller's API key
- `404` Screen or ad not found

#### OPTIONS /openrtb/v1/vast/{partnerKey}/{screenId}

CORS preflight for the partner-variant VAST endpoint

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `preflightVastByPartnerVariant`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `partnerKey` | path | `string` | yes |  |
| `screenId` | path | `string` | yes |  |

Responses:

- `204` CORS preflight response

#### GET /openrtb/v1/vast/{partnerKey}/{screenId}/debug

Debug view of VAST waterfall for a partner-variant alias

Returns the full waterfall decision log for the most recent VAST
request on this `partnerKey` + `screenId` pair: per-bidder bids,
no-bid reasons, cache lookups, blocklist hits, and timing
breakdowns. Intended for live debugging during partner onboarding.
Rate-limited per partner-variant.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `debugVastByPartnerVariant`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `partnerKey` | path | `string` | yes |  |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` Waterfall decision log + per-bidder breakdown
- `401` Partner-variant alias does not match the caller's API key
- `404` No recent waterfall for this screen + partner-variant pair

#### GET /openrtb/v1/win

Handle win notification

Win notification (`nurl`) fired by a DSP or exchange after it wins an
auction. Trillboards puts this URL on every bid response as

```
{API_URL}/openrtb/v1/win?price=${AUCTION_PRICE}&adid=<ad-id>&impid=<imp-id>
```

The `${AUCTION_PRICE}` macro is emitted **literally** — Trillboards never
expands it. Expanding it is the caller's job, and an unexpanded macro
arrives as a non-numeric `price` and is rejected.

## This endpoint does not bill

It validates the price, resolves the advertisement, checks the campaign
budget, and runs spend-anomaly detection whose only sink is a log line.
No revenue row, no `total_spent` increment, and no win flag is written.
Billing happens on `POST /openrtb/v1/proof-of-play`, the only path that
publishes a completed impression and fires the `burl`. Treat a `200` here
as "the win was accepted for validation", never as "the impression was
paid for".

## Response bodies are not JSON

Every branch except the `429` answers with `res.send(<string>)`, so the
Content-Type is `text/html; charset=utf-8` and the body is a bare
sentence. **Branch on the status code, never on the body.**

## Price handling

`parseFloat`, then a single guard: the value must be `> 0` and `<= 500`.
`parseFloat` is prefix-tolerant, so `price=1.5USD` is accepted as `1.5`.
There is no currency parameter on this endpoint — the price is
interpreted in the currency of the bid response that carried the `nurl`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `handleWinNotification`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `price` | query | `number` | yes | Clearing price, already macro-expanded. Must parse to a number strictly greater than 0 and no greater than 500. |
| `adid` | query | `string` | yes | Trillboards advertisement id, echoed from the bid response `adid`. |
| `impid` | query | `string` | yes | Impression id from the bid request. Required, but **presence-checked only** — it is not used to resolve or credit the impression. |

Responses:

- `200` Win accepted. Body is the literal string `OK`. A spend anomaly is logged but does NOT change this status.
- `400` Either a required parameter was missing (`Missing required parameters: price, adid, impid` — emitted when ANY of the three is absent, not only when all are) or the price failed the range guard (`Invalid win price`). An unexpanded `${AUCTION_PRICE}` lands here.
- `404` No advertisement matches `adid`. Body is `Advertisement not found`.
- `429` The campaign budget would be exceeded by this win (`total_spent + price > budget`). The only JSON body on this operation, and NOT a rate limit — do not retry it with backoff.
- `500` Unhandled server error. Body is `Internal server error`.

### Impression Tracking

Impression, proof-of-play, and viewability collection

#### GET /openrtb/v1/analytics/impressions

Get impression analytics

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getImpressionAnalytics`

Responses:

- `200` Impression rows plus a summary block. ## The viewability half of this response is permanently empty `viewabilityMetrics` is a hardcoded empty array: the collection that backed it was Mongo-only and has been retired, and no replacement is wired in here. Everything derived from it is therefore constant — `summary.viewableImpressions` is always `0`, `summary.avgViewableTime` is always `0`, and `summary.viewabilityRate` is always zero. Read viewability from `POST /openrtb/v1/viewability`'s own storage instead; do not report these three as a measured zero. `summary.viewabilityRate` is also **not a stable JSON type**: it is the string `"0.00"` when at least one impression matched, and the number `0` when none did. Parse it leniently. The row list is capped at 1000 server-side and is not paginated — there is no cursor and no total. Narrow with `startDate`/`endDate`.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/impression

Record impression

JSON form of the impression beacon. Same handler as
`GET /openrtb/v1/pixel`, so the ledger writes, the dedup rule, the
signature requirement, and the rate limit are all identical — see that
operation for the full mechanics.

Prefer this form over the pixel whenever the caller can read a response
body: every outcome here is machine-readable JSON, whereas the pixel
returns an opaque GIF for success, deduplication, signature rejection,
and server error alike.

**This beacon does not bill.** It records a *started* play. Revenue is
settled by `POST /openrtb/v1/proof-of-play`.

Two shape traps carried over from the shared handler:

- The signature travels in the **query string** (`?sig=`) even on this
  POST, and it covers query fields only — **the JSON body is entirely
  unsigned**.
- The body spells the ad id `adId` (camelCase), but the signature
  canonicalisation uses `adid` (lowercase). They are different names for
  the same value in different places.

`mediaType` is accepted by the parser and then discarded; do not rely on
it.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `recordImpression`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sig` | query | `string` | yes | Attribution signature — see `GET /openrtb/v1/pixel` for the exact construction. Required on this POST too, and computed over query parameters only. |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes | Advertisement id. Must be a JSON **string** — a numeric value reaches an unguarded `startsWith` and produces a 500. |
| `adSource` | `string` |  | Also accepted as `ad_source`, `source`, `src`. Self-promotion labels suppress downstream billing. |
| `adType` | `string` |  | Also accepted as `ad_type`. |
| `advertiserId` | `string` |  | Also accepted as `advertiser_id`. |
| `allocationId` | `string` |  | Loop-slot allocation the play was booked against. |
| `bidPrice` | `number` |  | Also accepted as `winPrice` or `win_price`. |
| `bid_response_id` | `string` |  | Also accepted as `bidResponseId`. |
| `campaignId` | `string` |  | Also accepted as `campaign_id`. |
| `creative_id` | `string` |  | Also accepted as `creativeId`. A leading `ima_` prefix is stripped before storage. |
| `currency` | `string` |  |  |
| `deviceId` | `string` |  | Also accepted as `device_id`, `fingerprint`, `deviceFingerprint`, `device_fingerprint`; falls back to the client IP. |
| `displayContext` | `object` |  | Presentation the ad was rendered in. |
| `impressionId` | `string` | yes | Impression id minted when the ad was served. |
| `request_id` | `string` |  | Auction id; takes precedence over `impressionId` in the dedup key. Also accepted as `requestId`, `bid_request_id`, `bidRequestId`, `auction_id`, `auctionId`. |
| `screenId` | `string` |  | Screen the ad played on; part of the dedup key. |

Responses:

- `200` Impression recorded, or recognised as a duplicate. Both outcomes are `200` — read `duplicate` to tell them apart. A rejected signature is ALSO `200`, with `success: false` and a `reason`; check `success` before treating the call as delivered.
- `400` `adId` or `impressionId` missing, or the advertisement could not be resolved or is not `approved`/`active`.
- `429` OpenRTB tracking rate limit exceeded — 300 requests per rolling 60s window by default, bucketed per partner SDK device (falling back to client IP). The ceiling is a live runtime-config tunable, so treat 300 as the default rather than a fixed contract, and drive retries from the `RateLimit-*` and `Retry-After` headers. The body is the limiter's own literal, NOT the canonical envelope.
- `500` Unhandled server error. Note this body echoes the raw internal exception message in `message`; do not surface it to end users.

#### POST /openrtb/v1/impressions/batch

Batch update impressions

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `batchUpdateImpressions`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `impressions` | array of `object` | yes | Updates to apply, one per impression. An empty array — or anything that is not an array — is a `400`. |

Responses:

- `200` The batch ran. **`200` does not mean every row succeeded** — this endpoint is partial-success by design: each entry is applied independently and a failure is collected rather than aborting the batch. Always read `results.failed` and `results.errors`; a batch where every entry failed still answers `200`.
- `400` `impressions` was missing, not an array, or empty.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/pixel

Track impression via pixel

Impression beacon fired from an `<img>` tag or a VAST `<Impression>`
node. Shares one handler with `POST /openrtb/v1/impression`; the two
differ only in how parameters arrive and in what a failure looks like on
the wire.

## This beacon does not bill

It writes `impression_ledger` and an `impression_events` row with
`status: "started"`, and increments delivery counters. It never writes
`completed_impressions`. Revenue is settled by
`POST /openrtb/v1/proof-of-play`; a fired pixel is evidence of a
*started* play, not of a paid one.

## The response is not always an image

Success, a deduplicated repeat, a rejected signature, and an unhandled
exception all return the same 42-byte transparent GIF with `200`. But a
missing-parameter or invalid-advertisement failure returns **JSON with a
`400`**, and the rate limiter returns **JSON with a `429`** — so an
`<img>` consumer sees a broken image with no way to read the reason.
Integrations that need to observe failures must use the POST form.

## Signature is required

`?sig=` is mandatory whenever the `VAST_ATTRIBUTION_REQUIRE_SIG` runtime
flag is on, which is the production default. It is a truncated HMAC —
see the `sig` parameter for the exact construction, and note that it
covers a **fixed nine-field subset** of the query string. `impid`,
`creative_id`, `campaign_id`, and `bid_price` are NOT signed, so
tampering with them does not invalidate the signature.

## Deduplication

Best-effort, via a Redis `SET NX EX 3600` on
`impression:dedup:{request_id or impid}:{screenId or "none"}`. Three
consequences worth designing around: the key uses `request_id` in
preference to `impid`; `adid` is **not** part of the key; and when Redis
is unreachable dedup is skipped entirely, while a Redis call slower than
50 ms is treated as a duplicate and the impression is silently dropped.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackImpressionPixel`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `adid` | query | `string` | yes | Advertisement id. Lowercase `adid` on this GET form — the POST form spells the same field `adId`, and the signature canonicalisation uses the lowercase spelling for both. |
| `impid` | query | `string` | yes | Impression id minted when the ad was served. |
| `sig` | query | `string` | yes | Attribution signature. HMAC-SHA256 over the canonical string, hex, **truncated to the first 32 characters**. The canonical string is these nine fields, in this exact order, each as `key=encodeURIComponent(value)` joined by `&`, with an absent value signing as empty: `adid`, `deviceId`, `event`, `provider`, `request_id`, `screenId`, `src`, `var`, `variant_name` Any other query parameter — and, on the POST form, the entire JSON body — is outside the signature. Requests are rejected when the signing secret is unset or shorter than 32 characters, so a misconfigured environment rejects every beacon rather than accepting unsigned ones. |
| `screenId` | query | `string` |  | Screen the ad played on. Part of the dedup key when present. |
| `request_id` | query | `string` |  | Auction/bid-request id. Takes precedence over `impid` in the dedup key. Also accepted as `requestId`, `bid_request_id`, `bidRequestId`, `auction_id`, or `auctionId`. |
| `allocationId` | query | `string` |  | Loop-slot allocation the play was booked against. camelCase only on this operation — `allocation_id` is not read here. |
| `deviceId` | query | `string` |  | Device identifier. Falls back through `device_id`, `fingerprint`, `deviceFingerprint`, `device_fingerprint`, then the `Referer` query string, then the client IP. |
| `creative_id` | query | `string` |  | Creative id. A leading `ima_` prefix is stripped server-side before storage, so `ima_abc` and `abc` resolve to the same creative. Also accepted as `creativeId`. |
| `campaign_id` | query | `string` |  | Also accepted as `campaignId`. |
| `advertiser_id` | query | `string` |  | Also accepted as `advertiserId`. |
| `bid_price` | query | `number` |  | Winning bid price. Coerced with `Number()`; a non-finite value is stored as null rather than rejected. Also accepted as `bidPrice`, `win_price`, or `winPrice`. |
| `currency` | query | `string` |  |  |
| `ad_source` | query | `string` |  | Demand source label. Also accepted as `adSource`, `source`, or `src`. Self-promotion labels here suppress downstream billing. |
| `ad_type` | query | `string` |  | Also accepted as `adType`. |
| `bid_response_id` | query | `string` |  | Also accepted as `bidResponseId`. |
| `type` | query | `string`: one of `error` |  | **Carve-out.** `type=error` is checked before everything else — even before the signature gate — and delegates the whole request to the error-event handler. It is not an impression at all. Leave this parameter off unless you are firing a VAST error beacon. |

Responses:

- `200` A 42-byte transparent GIF, returned identically for a recorded impression, a deduplicated repeat, a rejected signature, and an unhandled server error. **The image carries no outcome** — a `200` GIF is not confirmation that the impression was stored.
- `400` `adid` or `impid` missing, or the advertisement could not be resolved or is not in an `approved`/`active` state. **JSON, not an image** — an `<img>` consumer sees only a broken image here.
- `429` OpenRTB tracking rate limit exceeded — 300 requests per rolling 60s window by default, bucketed per partner SDK device (falling back to client IP). The ceiling is a live runtime-config tunable, so treat 300 as the default rather than a fixed contract, and drive retries from the `RateLimit-*` and `Retry-After` headers. The body is the limiter's own literal, NOT the canonical envelope.

#### POST /openrtb/v1/proof-of-play

Record MRC DOOH proof-of-play

MRC-aligned DOOH proof-of-play, fired by the screen application when an
ad finishes or is cut short.

## This is the billing endpoint

Of every beacon on this surface, this is the one that settles revenue.
On a qualifying call it publishes a `completed_impressions` row and
fires the advertiser's billing URL (`burl`). Both are gated on ALL of:

- the play completed — `completionType` is `full` or `complete`;
- the invalid-traffic check passed (`givt.passed`);
- the creative is not self-promotional inventory;
- the demand provider resolves to something other than `unknown`
  (an unresolved provider is dropped silently, still returning `200`).

`POST /openrtb/v1/impression` and `GET /openrtb/v1/pixel` record a
*started* play and never bill. `GET /openrtb/v1/win` validates a win and
never bills.

## There is no deduplication

Unlike the impression beacon, this endpoint has no dedup key. Every call
mints a fresh `playId` and can publish a fresh billable row. **A blind
retry double-bills.** Retry only on a transport failure where you have
no response at all, and prefer making the play itself idempotent
upstream.

## The returned signature is minted by us, not verified from you

`signature` in the response is a receipt Trillboards generates so a
buyer can verify the play independently. Nothing in the request carries
an Ed25519 signature, and none is checked. The only credential you send
is the HMAC `?sig=` query parameter.

The signed payload is six dot-joined fields in this exact order:

```
<signatureVersion>.<timestampMs>.<adId>.<impressionId>.<screenId>.<deviceId>
```

where `timestampMs` is `playStartTime` in epoch **milliseconds**, and
`impressionId` falls back to the generated `playId` when you omit it.
`completionType`, the durations, `givt`, `channel`, and `displayContext`
are NOT covered. Verifiers must accept both prefixes: `ed25519=` (v2,
the normal path) and `sha256=` (v1, the fallback when no Ed25519 signing
key is configured).

## Server-authoritative fields

`impressionMultiplier` is resolved server-side from the audience-quantity
audit for the auction and fails closed to `1`. A client-supplied value is
read from the body and then discarded — sending it changes nothing.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordProofOfPlay`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sig` | query | `string` | yes | Attribution signature — construction is identical to `GET /openrtb/v1/pixel`. Computed over query parameters only, so the JSON body below is unsigned. |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `actualDuration` | `number` |  | Seconds actually played. When omitted it is derived as `(playEndTime - playStartTime) / 1000`. |
| `adId` | `string` | yes | Advertisement id. Must be a JSON **string** — a numeric value reaches an unguarded `startsWith` and produces a 500. |
| `ad_source` | `string` |  | Also accepted as `adSource`, `source`, `src`. Self-promotion labels here suppress both the billable row and the `burl`. |
| `ad_type` | `string` |  | Also accepted as `adType`. |
| `allocation_id` | `string` |  | Also accepted as `allocationId`, and read from the query string when absent from the body. |
| `campaignId` | `string` |  |  |
| `channel` | `string` |  | Delivery channel. Forced to `ctv` when the `User-Agent` identifies the CTV agent, otherwise this value, otherwise `dooh`. |
| `completionType` | `string` | yes | How the play ended. Only `full` and `complete` are billable; any other value is recorded but never settles revenue. The field is not enum-validated server-side, so an unrecognised value is accepted and simply never bills. |
| `creative_id` | `string` |  | Also accepted as `creativeId`. When omitted it is derived from `adId` by stripping a leading `ima_`. |
| `deviceInfo` | `object` |  | Device and player context captured at play time. |
| `displayContext` | `object` |  | Presentation the ad rendered in. `displayMode`/`mode` and `displayAreaRatio`/`areaRatio` are both accepted spellings. |
| `fill_source` | `string` |  | Also accepted as `fillSource`. Read here only — the impression beacon ignores it. |
| `impressionId` | `string` |  | Impression id from the serve. **Optional**, despite what older integration notes say — when omitted, the generated `playId` is substituted into the signed payload, which changes what a verifier must reconstruct. |
| `playEndTime` | `string` |  | Defaults to the server clock. |
| `playStartTime` | `string` |  | Defaults to the server clock. Supplies the signed timestamp. |
| `request_id` | `string` |  | Auction id. Also accepted as `requestId`, `bid_request_id`, `bidRequestId`, and — uniquely on this operation — read from the query string when absent from the body, then from `auctionId`/`auction_id`. |
| `scheduledDuration` | `number` |  | Seconds the slot was scheduled for. |
| `screenId` | `string` | yes | Screen the ad played on. |
| `screenStatus` | `string` |  | Defaults to `functional`. |
| `winPrice` | `number` |  | Clearing price for this play, when the caller knows it. |

Responses:

- `200` Proof-of-play recorded. Also the status returned when the attribution signature was rejected — in that case `success` is `false` and `reason` is `invalid_attribution_signature`, and nothing was recorded. **Check `success` before treating the play as settled.** A failed invalid-traffic check does NOT change the status either: it returns `200` with `givt.passed: false`, and suppresses the billable row and the `burl`.
- `400` `screenId`, `adId`, or `completionType` is missing.
- `500` Unhandled server error. Nothing is guaranteed to have been recorded; because this endpoint does not deduplicate, treat a retry as potentially double-billing.

#### POST /openrtb/v1/viewability

Record viewability telemetry

Records viewable-time telemetry against an impression already recorded by
the impression beacon. Not billable — it only updates viewability columns
on the existing impression row.

This is the only beacon on this surface with **no attribution signature
and no rate limit**; it is completely open.

All input combinations return the documented `200`, including
`viewable: true` with `viewableTime >= 1000`.

> **Fixed.** Until this release, `viewable: true` **and**
> `viewableTime >= 1000` answered `500 {"error":"Internal server error"}`:
> the route wires the handler as a bare static reference, so `this` was
> `undefined` and a sibling call threw. Because the PostgreSQL write is
> awaited *before* that branch, the telemetry was stored and the caller
> was told it had failed. Retrying was harmless — the write is an
> idempotent `UPDATE` keyed on `impressionId`, never an insert, so a
> retry rewrote the same row with the same values and could not duplicate
> anything. Nothing needs to be resent or reconciled.

`screenSize`, `adSize`, and `intersection` are parsed off the body and
then discarded — sending them has no effect.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackViewability`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes | Advertisement id. Must be a JSON **string** — a numeric value reaches an unguarded `startsWith` and produces a 500. |
| `deviceId` | `string` |  |  |
| `impressionId` | `string` | yes | Impression the telemetry attaches to. |
| `percentInView` | `number` |  | Fraction or percentage of the ad in view. Defaults to `0`. |
| `screenId` | `string` |  |  |
| `viewable` | `boolean` |  | Whether the ad met the viewability threshold. Defaults to `false`. |
| `viewableTime` | `integer` |  | Milliseconds the ad was viewable. Defaults to `0`. A value of 1000 or more combined with `viewable: true` additionally records a viewable impression. |

Responses:

- `200` Viewability telemetry recorded. Returned for every accepted request, including the `viewable && viewableTime >= 1000` case.
- `400` `impressionId` or `adId` missing. The message does not say which.
- `500` Unhandled server error. Nothing is guaranteed to have been written. A retry is safe: the PostgreSQL write is an idempotent `UPDATE` keyed on `impressionId`, never an insert, so replaying a request rewrites the same row with the same values and cannot duplicate anything.

#### GET /openrtb/v1/win-notice/{token}

Relay a demand partner's win notice at ad start

The `start` tracking event a demand partner's win notice rides in. For a
partner whose notice belongs at ad start (Playdigo), the VAST returned by
`GET /openrtb/v1/vast/{partnerKey}/{screenId}` carries this URL as a
`<Tracking event="start">` instead of firing the partner's `nurl` when the
bid arrives. The player calls it on the ad's first frame; the server
answers with the tracking pixel at once and then calls the stored `nurl`,
once per bid. A bid the player never starts never reaches the partner.

The `token` is a one-time key minted per bid. Unknown, expired, repeated
and malformed tokens get the same pixel and call no one.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `relayPartnerWinNotice`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | `string` | yes | 32 lowercase hex characters, minted per bid. |

Responses:

- `200` A 42-byte transparent GIF, identical for every token. The image carries no outcome.
- `429` OpenRTB tracking rate limit exceeded — 300 requests per rolling 60s window by default, bucketed per partner SDK device (falling back to client IP). The ceiling is a live runtime-config tunable, so treat 300 as the default rather than a fixed contract, and drive retries from the `RateLimit-*` and `Retry-After` headers. The body is the limiter's own literal, NOT the canonical envelope.

### Event Tracking

Completion, heartbeat, performance, and event analytics

#### GET /openrtb/v1/analytics/events

Get event analytics

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getEventAnalytics`

Responses:

- `200` Counts grouped by event type, a recent-event tail, and — only sometimes — performance metrics. `performanceMetrics` is `null` unless you either omit `eventType` or set it to `performance`; any other filter returns `null` rather than an empty object. It is **also** `null` when no performance samples exist in the window, so `null` does not distinguish "not requested" from "nothing recorded". `summary[]._id` is a constant `0` — a serialisation artifact, not an identifier. Group on `eventType`.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/complete

Track completion event

Ad-completion beacon for custom players — integrations that render VAST
themselves instead of using the IMA SDK. It is the completion counterpart
to the impression beacon on the same auction.

## This endpoint settles revenue

On a qualifying call it publishes a `completed_impressions` row with
ingestion path `vast_event`. Four independent gates must all pass:

- the invalid-traffic check passed (`givt.passed`);
- the creative is not self-promotional inventory;
- the demand provider resolves to something other than `unknown`;
- an idempotency claim on
  `(screenId, requestId, adId, impressionId, provider)` was won by this
  call — a second call for the same tuple writes nothing.

The publish is fire-and-forget: the `200` is returned regardless, and a
failure in the billing pipeline is logged, not surfaced. **A `200` here
does not confirm that a billable row landed.**

It differs from `POST /openrtb/v1/proof-of-play` in two ways that matter:
this endpoint carries no price, no play id and no signature, and unlike
proof-of-play it **is** idempotent, so a retry is safe.

## Rate-limit key trap

The limiter buckets on `device_id` (snake_case) read from the body,
falling back to client IP. The handler itself reads `deviceId`
(camelCase). A caller that sends only `deviceId` is therefore rate-limited
**by IP**, which on a shared NAT is a single 300/min bucket for the whole
fleet. Send `device_id` as well if you want per-device budgeting.

## Two different 200 bodies

The deduplicated response carries `givt.flags` and **no** `impressionId`;
the recorded response carries `impressionId` and no `givt`. Do not assume
either field is present.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackCompletion`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes | Advertisement id. Maximum 256 characters. |
| `ad_source` | `string` |  | Also accepted as `adSource`, `source`, `src`. A self-promotion label suppresses the billable row. |
| `ad_type` | `string` |  | Also accepted as `adType`. Self-promotion labels are matched case- and separator-insensitively, so `self_promo`, `self-promo`, and `SelfPromo` are equivalent. |
| `creativeId` | `string` |  | Also accepted as `creative_id`. A leading `ima_` prefix is stripped before storage. |
| `deviceId` | `string` |  | Device identifier used by the handler. Falls back to the client IP. See the rate-limit key trap above — the limiter reads `device_id`, not this field. |
| `device_id` | `string` |  | Rate-limit bucket key. Read by the limiter only; the handler ignores it. |
| `duration` | `number` |  | Seconds played. Kept only when finite and within `0..3600`; an out-of-range value is silently dropped rather than rejected. |
| `impressionId` | `string` |  | Impression id from the serve. Optional — when omitted, a deterministic id of the form `custom_<screenId>_<adId>_<hourBucket>` is derived, which collapses every completion for that screen and ad within the same hour into one deduplication bucket. Send a real id if you play the same creative more than once an hour. |
| `screenId` | `string` | yes | Screen the ad played on. **Strictly validated** — must be exactly 24 hexadecimal characters, unlike every other beacon on this surface. |

Responses:

- `200` Completion accepted — either recorded, or recognised as a duplicate of an earlier completion for the same hour bucket. The two cases have different bodies; read `message`.
- `400` `screenId` is absent or is not 24 hex characters, or `adId` is absent or longer than 256 characters. The two cases have distinct messages.
- `429` Programmatic event rate limit exceeded — 300 requests per rolling 60s window, bucketed on `device_id` from the request body when present and on client IP otherwise.
- `500` Unhandled server error.

#### GET /openrtb/v1/event

Track event via GET (pixel)

Track an ad event via GET request. Returns a 1x1 transparent GIF pixel.
Used by VAST tracking URLs embedded in creatives. Includes GIVT (General
Invalid Traffic) filtration with deduplication, screen liveness, and demo detection.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackEventGet`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `adid` | query | `string` | yes | Advertisement ID |
| `event` | query | `string`: one of `start`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `impression`, `viewable`, `notviewable`, … | yes | Event type |
| `impid` | query | `string` |  | Impression ID for correlation |
| `screenId` | query | `string` |  | Screen ID |
| `deviceId` | query | `string` |  | Device ID (partner SDK sends P_* IDs) |
| `errorCode` | query | `integer` |  | IMA error code (when event=error) |
| `vastErrorCode` | query | `integer` |  | VAST error code (when event=error) |

Responses:

- `200` A 43-byte 1x1 transparent GIF, always — this operation never returns JSON and never returns a non-200. **Every outcome looks identical on the wire.** A missing `adid`, a malformed ad id, a rejected attribution signature, a deduplicated event and an unhandled server error all return the same pixel with the same `200`. That is deliberate: these URLs are fired by `new Image()` and by VAST tracking nodes, where a JSON body trips Chrome's Opaque Response Blocking and a non-200 shows as a broken asset. The consequence for you is that **the GET beacon cannot be used to confirm anything was recorded** — use `POST` on the same path when you need a verdict. Sent with `Cache-Control: no-store, no-cache, must-revalidate, private` so intermediaries cannot collapse repeat fires.

#### POST /openrtb/v1/event

Track event via POST (JSON)

Track an ad event via POST request. Returns a JSON acknowledgment.
Includes GIVT filtration. Deduplicated events return success with givt flags.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackEventPost`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `adid` | query | `string` | yes | Advertisement ID |
| `event` | query | `string` | yes | Event type (start, complete, impression, error, etc.) |
| `impid` | query | `string` |  | Impression ID |
| `screenId` | query | `string` |  | Screen ID |
| `deviceId` | query | `string` |  | Device ID |

Responses:

- `200` The request was handled. **Three different bodies share this status, and one of them means the event was rejected** — always branch on `success`, never on the status code. - **Recorded** — `success: true` with `message: "Event <event> tracked successfully"`. - **Deduplicated** — `success: true`, `message: "Event <event> deduplicated"`, and a `givt.flags` array. The event was recognised as a repeat and **not** recorded again; treat it as already-counted, not as newly counted. - **Rejected signature** — `success: false` with `reason: "invalid_attribution_signature"` and no `message`. Nothing was recorded. This is an explicit `200`, not an error status, so a client that only checks `res.ok` will silently lose every event it mis-signs. `message` interpolates the `event` query parameter verbatim, so it is a echo of your input rather than a controlled vocabulary — match on `success` and `reason`, not on the message text.
- `400` Missing required parameters

#### DELETE /openrtb/v1/events/cleanup

Delete old events

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `cleanupOldEvents`

Responses:

- `200` **This endpoint does nothing.** It deletes no rows, reads no database, and ignores every query parameter. `deletedCount` is the literal `0` on every call. Event retention is automated — PostgreSQL partition rollover plus a 90-day ClickHouse TTL — so there is no manual sweep left to run. The route is kept only so existing callers do not break, and it reports that state in `message`. Do not schedule it, and do not read `deletedCount: 0` as "nothing needed cleaning".
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/heartbeat

Track registered-screen heartbeat and targeting identities

Records liveness, device telemetry, the actual resettable platform
advertising ID, and typed host-owned audience identities for a
registered device/screen.

This POST is intentionally unauthenticated. Supply either the stable
registered device fingerprint or its bound `screenId`; the resolved
registered-device relationship is the trust and attribution boundary.
Do not send a Partner API key or device JWT.

`advertising_identity.id` is the real GAID/AAID (or another supported
platform ID), never a screen UUID. `hem_sha256` is an existing
one-pass SHA-256 email digest and is not hashed again. `uid2_token` is
opaque and case-sensitive; send it exactly as issued with its expiry.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `trackHeartbeat`

Request body (`application/json`, required): `DeviceHeartbeatBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertising_id` | `string`, nullable |  | Legacy MAID field; prefer `advertising_identity`. |
| `advertising_id_type` | `string`: one of `gaid`, `aaid`, `afai`, `rida`, `tifa`, `idfa`, nullable |  |  |
| `advertising_identity` | `AdvertisingIdentity` |  |  |
| `capabilities` | `object` |  |  |
| `extended_identities` | array of `ExtendedIdentity` |  |  |
| `fingerprint` | `string` |  | Stable registered device external ID or fingerprint. |
| `limit_ad_tracking` | `boolean`, nullable |  |  |
| `metadata` | `object` |  |  |
| `schema_version` | `integer` |  |  |
| `screenId` | `string` |  | Bound screen ID when already known by the device. |
| `status` | `string` |  |  |

Responses:

- `200` Heartbeat recorded. ## Read `screenId` back — it is a correction channel The `screenId` in the response is the **authoritative** binding for the device that called, and it may differ from the one you sent. A device that has been rebound is told so here and is expected to adopt the returned value. ## Most of the body is conditional Only `success`, `message`, `timestamp`, `server_time`, `screenId` and `deviceUpdated` are always present. `socket_token`, `commands`, `sensing_config`, `deviceInfo`, `mdm_enroll` and `capabilityWriteRejectedReason` each appear only when they apply — an absent key is not an empty one. A heartbeat carrying no telemetry takes a short-circuit path that pins `deviceUpdated: false` and can never include `deviceInfo`, `mdm_enroll` or `capabilityWriteRejectedReason`; it still returns pending `commands` and a refreshed `socket_token`. ## `socket_token` expires in five minutes It is minted per heartbeat with a 5-minute TTL, so the ~30s heartbeat cycle is what keeps it alive. It authenticates the realtime socket only. **It is not the device's API credential** and must not be stored where the long-lived device token is read from — doing so makes one missed heartbeat look like a revoked device.
- `400` Neither a resolvable fingerprint nor screenId was supplied
- `404` Registered device or screen binding was not found

#### POST /openrtb/v1/owned-direct/play-token/{screenId}

Mint signed lifecycle URLs for one Schedule direct play

Mints a fresh, replay-safe lifecycle map for one currently servable
Schedule-mode direct placement. The device token and path both bind the
caller to `screenId`; the server revalidates the `(adId, screenId)`
placement and rejects self-promo before signing anything.

Every returned URL contains the same server-issued, HMAC-bound
`request_id`. Fire the returned lifecycle URLs verbatim. A client
impression ID may be supplied for diagnostics, but it is not the
billing or replay identity. Responses are private and non-cacheable.

- Auth: `deviceTokenAuth` header `X-Device-Token`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `mintOwnedDirectPlayToken`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | MongoDB ObjectId of the screen bound to the device token. |
| `X-Device-Timestamp` | header | `string` | yes | Current Unix time in milliseconds for replay protection. |
| `X-Device-Nonce` | header | `string` | yes | Unique nonce for this authenticated mint request. |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes | Advertisement ObjectId for the direct placement. |
| `impressionId` | `string` |  | Optional client correlation value; never used as the signed replay identity. |
| `screenId` | `string` |  | Optional body echo; the authenticated path value is authoritative. |

Responses:

- `200` Fresh signed lifecycle map for exactly one direct play.
- `400` Invalid screen/ad identity or missing replay-guard input.
- `401` Device token is missing, invalid, or no longer registered.
- `403` Device token is not bound to the requested screen.
- `404` Placement is not currently servable or is self-promo.
- `429` Per-screen OpenRTB tracking rate limit exceeded.
- `500` Placement revalidation or token mint failed unexpectedly.
- `503` Attribution signing is unavailable; the mint fails closed.

#### POST /openrtb/v1/performance

Track performance telemetry

Player and device health telemetry for a screen. Diagnostic only — it
never touches delivery counts, impressions, or revenue.

Both writes are fire-and-forget, so a `200` means the payload was
accepted and dispatched, not that it was durably stored.

Only the seven metric keys enumerated below are read; any other key
inside `metrics` is discarded silently. Values are copied through with no
validation or unit coercion, so send them in the units documented here
or the series will be uncomparable across fleets.

Unlike `POST /openrtb/v1/complete`, `screenId` is only checked for
truthiness — there is no 24-hex validation and no rate limit on this
endpoint.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `trackPerformance`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` |  | Advertisement the sample was taken during, when the sample is ad-scoped. Never validated. |
| `metrics` | `object` | yes | Metric bag. Only these seven keys are persisted; everything else is dropped. |
| `screenId` | `string` | yes | Screen the telemetry describes. Presence-checked only. |

Responses:

- `200` Telemetry accepted. Also returned when the durable write fails — the writes are fire-and-forget.
- `400` `screenId` or `metrics` is missing. The message does not say which.
- `500` Unhandled server error.

### Interaction Tracking

Interaction and QR scan analytics

#### GET /openrtb/v1/analytics/interactions

Get interaction analytics

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getInteractionAnalytics`

Responses:

- `200` Interaction totals and a type breakdown. ## This surface reports zero and always will The collection it aggregated was Mongo-only and is retired; PostgreSQL `interactions` is canonical and nothing here reads it. Every count is a hardcoded literal, so the response below is not "no data in your window" — it is the only response this operation can produce, for any window, for any account. Do not chart it, alert on it, or report it as measured zero engagement.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/analytics/qr-scans

Get QR scan analytics

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getQrScanAnalytics`

Responses:

- `200` QR scan totals, a period series, and a top-ads leaderboard. ## This surface reports zero and always will The collection it aggregated was Mongo-only and is retired; PostgreSQL `interactions` is canonical and nothing here reads it. Every count is a hardcoded literal, so the response below is not "no data in your window" — it is the only response this operation can produce, for any window, for any account. Do not chart it, alert on it, or report it as measured zero engagement.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/interaction

Track ad interaction

Records a single viewer interaction with a playing ad — a touch, a click,
a QR scan. Analytics only; it never affects delivery counts or revenue.

The durable write is fire-and-forget and the `interactionId` in the
response is minted before it settles, so a `200` with an id is not
confirmation that the row persisted.

`interactionType` is **not** validated against an enum — any string is
accepted and stored verbatim. The values the reporting side actually
queries are `qr_scan` and `qr_interaction` for the QR report; `click` and
`touch` are the other conventional values. Inventing a new value will
store cleanly and then be invisible to every existing report.

`metadata.userAgent` and `metadata.ip` are **overwritten** server-side
from the request headers and socket. Anything you send under those two
keys is discarded.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `trackInteraction`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `interactionType` | `string` | yes | Interaction kind. Free-form; see the note above about which values the reports recognise. |
| `metadata` | `object` |  | Free-form context. `userAgent` and `ip` are reserved — they are replaced with server-observed values. |
| `relativeTime` | `integer` |  | Milliseconds since the ad started playing. |
| `timestamp` | `integer` |  | Epoch milliseconds. Defaults to the server clock when omitted. |
| `trackingId` | `string` | yes | Session key correlating this interaction with the play it belongs to, and with a later `POST /openrtb/v1/interaction-summary`. |

Responses:

- `200` Interaction accepted. `interactionId` is generated before the durable write completes; a storage failure is logged and does not change this status.
- `400` `trackingId` or `interactionType` is missing.
- `500` Unhandled server error. `error` echoes the raw internal exception message; do not surface it to end users.

#### POST /openrtb/v1/interaction-summary

Submit interaction summary

Submits a whole interaction session in one call — the batch counterpart
to `POST /openrtb/v1/interaction`. Analytics only; no billing effect.

## The array items use a different field name

Each element of `interactions` names its kind `type`, **not**
`interactionType` as the single-interaction endpoint does. An item
sending `interactionType` stores a null kind and is invisible to every
report. This is the most common mistake on this endpoint.

Two more asymmetries against the single-interaction endpoint: items get
**no** `timestamp` default, so an item without one stores a null
timestamp; and `userAgent`/`ip` are **not** injected here.

The top-level `metadata` is merged into every item's own metadata and
**wins on key collisions**, so a session-wide `campaignId` cannot be
overridden per item.

## The response is an echo, not a receipt

`summary` restates what you sent plus a server timestamp. It contains no
ids and no write confirmation. The batch insert is awaited but its errors
are swallowed, so a `200` can accompany a failed write. `interactionCount`
is echoed verbatim and is **never** cross-checked against
`interactions.length`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `trackInteractionSummary`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `duration` | `integer` |  | Session length in milliseconds. Echoed, never validated. |
| `interactionCount` | `integer` |  | Caller's own count. Echoed back unchanged and never compared with `interactions.length`. |
| `interactions` | array of `object` |  | Individual interactions. Written only when this is an array; omitting it stores nothing and still returns `200`. |
| `metadata` | `object` |  | Session-wide context. Echoed in `summary` AND merged into every item, overriding item keys of the same name. |
| `trackingId` | `string` | yes | Session key. The only validated field on this endpoint. |

Responses:

- `200` Summary accepted. `summary` is an echo of the request plus a server timestamp — it is not a write receipt, and a failed batch insert still returns this response.
- `400` `trackingId` is missing.
- `500` Unhandled server error. `error` echoes the raw internal exception message.

### VAST Telemetry

VAST request outcome telemetry, creative blocklists, and cache analytics

#### GET /openrtb/v1/creative-blocklist

Get creative blocklist

Creative ids a player must refuse to render. Poll this and drop any
matching creative from a VAST response before it reaches the screen.

Called without `screen_id` it returns the **global** blocklist — every
creative reviewed and marked blocked platform-wide. Called with a
`screen_id` it returns the global list unioned with the creatives that
violate that screen's own content preferences, deduplicated. Read
`personalized` to know which of the two you received.

The global list is cached for five minutes, so a newly blocked creative
can take that long to appear.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeBlocklist`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screen_id` | query | `string` |  | Screen to personalise the list for. Omit for the global list. An unknown id is a `404`, not an empty list. |

Responses:

- `200` Blocklist for the requested scope.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` The authenticated caller does not have access to the `screen_id` given. Screen ownership is bound by requireScreenOwnership, so a valid session cannot read another owner's screen.
- `404` No screen matches `screen_id`.

#### GET /openrtb/v1/vast-cache/resolve/{creativeId}

Resolve a cached VAST creative by ID

Returns the resolved cache entry for `creativeId` — the cached media
URL on Trillboards' CDN, content-length, MIME type, first-seen and
last-seen timestamps, plus the upstream source URL. Screens hit this
endpoint after parsing a `<MediaFile>` element to decide whether to
stream from cache or upstream.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `resolveVastCacheEntry`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Responses:

- `200` Cache entry
- `404` No cache entry for `creativeId`

#### POST /openrtb/v1/vast-request

Track VAST request outcome

Track pending, filled, no-fill, error, timeout, and completion states.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordVastRequest`

Responses:

- `200` Telemetry accepted. The body is exactly `{"success": true}` — there is no id, no echo, and no per-field verdict, so there is nothing to correlate a later lookup against beyond the `request_id` you sent. The acknowledgement is returned **before** creative side effects run, so a `200` means the row was accepted, not that downstream creative processing finished. This is a `sendBeacon` target and is deliberately unauthenticated; it accepts a `text/plain` body as well as JSON, because `navigator.sendBeacon` cannot set a `Content-Type` header.

#### GET /openrtb/v1/vast-request/analytics

Get VAST request analytics

Fill-rate and latency outcomes for VAST requests, aggregated by waterfall
variant over a bounded time window.

## Rates here are fractions, not percentages

`fill_rate`, `completion_rate`, and `effective_rate` are values in `0..1`
rounded to four decimal places — a fill rate of 8% is `0.08`. The cache
analytics endpoints on this same surface report percentages instead
(`hit_rate_pct`, `0..100`), so do not share a formatter between them.

## The window is clamped

The lookback is capped at 14 days: a `start_date` older than that is
silently moved forward, an `end_date` in the future is pulled back to
now, and an `end_date` before the effective start is pushed up to it. An
unparseable date falls back to the default rather than erroring. Read
`period.start`, `period.end`, and `period.clamped` from the response to
learn the window you were actually served — never assume you got the
window you asked for.

Served from ClickHouse, falling back to an hourly PostgreSQL rollup. Both
engines produce the same field set, so the source is not distinguishable
from the response.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVastRequestAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` |  | Window start. Defaults to 24 hours ago. Clamped forward to at most 14 days ago. |
| `end_date` | query | `string` |  | Window end. Defaults to now, and is never allowed past now or before the effective start. |
| `group_by` | query | `string`: one of `variant`, `device_category` |  | Grouping dimension. `device_category` switches the breakdown; every other value — including a misspelling — silently groups by variant while still being echoed back verbatim in `group_by`. Compare the echoed value against what you sent to detect a typo. |

Responses:

- `200` Fill-rate outcomes for the served window.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` Authenticated but not an operator. This response is unscoped fleet-wide — the handler applies no tenant or screen filter — so it requires the admin role, not merely a session.
- `500` The analytics backend failed. Emitted as the canonical error envelope.

### Bandwidth Telemetry

Video cache and bandwidth savings telemetry

#### POST /openrtb/v1/bandwidth-metric

Track bandwidth telemetry

Reports a screen's daily bandwidth consumption and cache effectiveness,
split between ad video and content video.

## Counters accumulate, they are not set

The row is keyed on `(screen_id, date, connection_type)` where `date` is
always the **server's** current date — it is not an input. On a repeat
call for the same key, all eight counters are **added** to what is
already stored; the descriptive fields are filled in only where they were
previously null. So send deltas since your last call, never running
totals, or the day's figures will compound. A retry after an ambiguous
failure will double-count.

Note that `connection_type` is part of the key, so a screen that switches
from wifi to cellular mid-day accumulates into two separate rows.

Only `application/json` is parsed; a `sendBeacon` defaulting to
`text/plain` arrives with an empty body and is rejected as a missing
`screen_id`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordBandwidthMetric`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_video_bytes_cached` | `integer` |  | Bytes served from cache. Defaults to 0. Accumulates. |
| `ad_video_bytes_fetched` | `integer` |  | Bytes pulled over the network. Defaults to 0. Accumulates. |
| `ad_video_cache_hits` | `integer` |  | Defaults to 0. Accumulates. |
| `ad_video_cache_misses` | `integer` |  | Defaults to 0. Accumulates. |
| `bandwidth_mbps` | `number` |  | Downlink estimate. A non-finite value is stored as null rather than rejected. |
| `connection_type` | `string` |  | Defaults to `unknown`. **Part of the accumulation key** — a different value writes to a different row for the same screen and day. |
| `content_cache_bytes_fetched` | `integer` |  | Defaults to 0. Accumulates. |
| `content_cache_bytes_saved` | `integer` |  | Defaults to 0. Accumulates. |
| `content_cache_hits` | `integer` |  | Defaults to 0. Accumulates. |
| `content_cache_misses` | `integer` |  | Defaults to 0. Accumulates. |
| `device_class` | `string` |  | Defaults to `unknown`. |
| `device_id` | `string` |  |  |
| `effective_type` | `string` |  | Network Information API effective type. |
| `latency_rtt_ms` | `integer` |  | Round-trip estimate. Non-finite values store as null. |
| `save_data` | `boolean` |  | Data-saver preference. Must be a real boolean — the string `"true"` is silently stored as null. |
| `screen_id` | `string` | yes | Screen reporting the usage. |

Responses:

- `200` Metric accumulated. The body is exactly `{"success": true}`.
- `400` `screen_id` is missing.
- `500` The write failed. Unlike the VAST cache metric, this endpoint's write is **not** best-effort — a database error surfaces here, so nothing was accumulated and a retry is safe.
- `503` The PostgreSQL backend is unavailable. Retry with backoff.

#### GET /openrtb/v1/bandwidth/analytics

Get aggregated bandwidth analytics

Fleet-wide bandwidth savings over a date range, with daily, device-class,
and connection-type breakdowns.

## Numeric fields in the breakdowns are JSON strings

Only `summary` is coerced to numbers. `daily`, `by_device_class`, and
`by_connection_type` are passed through from the database unmodified, so
every sum and count in them arrives as a **string** (`"1000"`, not
`1000`) because the underlying columns are 64-bit integers. `date` in
`daily` likewise serialises as a full ISO-8601 timestamp, not a bare
calendar date. Coerce before doing arithmetic, and do not type these as
integers in a generated client.

`hit_rate_pct` here is a percentage to **two** decimal places — a third
rounding convention alongside the fraction used by the VAST request
analytics and the one-decimal percentage used by the cache analytics.

Dates are passed straight into the query as `date` values, so an
unparseable `start_date` produces a `500` rather than falling back.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getBandwidthAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` |  | Inclusive start, `YYYY-MM-DD`. Defaults to 7 days ago. |
| `end_date` | query | `string` |  | Inclusive end, `YYYY-MM-DD`. Defaults to today. |
| `device_class` | query | `string` |  | Restricts every block, including the breakdowns. |
| `screen_id` | query | `string` |  | Restricts every block to one screen. |
| `connection_type` | query | `string` |  | Restricts every block to one connection type. |

Responses:

- `200` Bandwidth savings for the requested range.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` Authenticated but not an operator. This response is unscoped fleet-wide — the handler applies no tenant or screen filter — so it requires the admin role, not merely a session.
- `500` The query failed, or a supplied date could not be parsed.
- `503` The PostgreSQL backend is unavailable. Retry with backoff.

#### GET /openrtb/v1/bandwidth/screen-usage

Get per-screen bandwidth usage

Bandwidth and cache history for a single screen, broken out by day and
summarised per connection type. The per-screen counterpart to
`GET /openrtb/v1/bandwidth/analytics`.

Both arrays are returned exactly as the database produced them, so —
as on that endpoint — **the 64-bit and fixed-point numeric fields arrive
as JSON strings**, and `date` is a full ISO-8601 timestamp. The two
plain-integer counters in `daily` (`total_cache_hits`,
`total_cache_misses`) are the exception and come back as numbers; in
`summary_by_connection_type` even those are strings, because they are
summed. Coerce before doing arithmetic.

Each `daily` row is one `(date, connection_type)` pair, so a screen that
changed connection during a day contributes more than one row for that
day.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenBandwidthUsage`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screen_id` | query | `string` | yes | Screen to report on. |
| `start_date` | query | `string` |  | Inclusive start, `YYYY-MM-DD`. Defaults to 30 days ago. |
| `end_date` | query | `string` |  | Inclusive end, `YYYY-MM-DD`. Defaults to today. |

Responses:

- `200` Usage history for the screen.
- `400` `screen_id` is missing.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` The authenticated caller does not have access to the `screen_id` given. Screen ownership is bound by requireScreenOwnership, so a valid session cannot read another owner's screen.
- `500` The query failed, or a supplied date could not be parsed.
- `503` The PostgreSQL backend is unavailable. Retry with backoff.

#### POST /openrtb/v1/vast-cache-metric

Track VAST cache metric

Reports one VAST video cache hit or miss from a screen, so bandwidth
savings can be attributed.

`cache_hit` is the only required field and is **strictly typed** — it
must be a JSON boolean. The string `"true"` is rejected with a `400`.
Every other field is optional and, when absent, defaults as documented
below rather than erroring.

## A 200 does not mean the row landed

Both the PostgreSQL insert and the ClickHouse mirror are best-effort:
each is wrapped in its own error trap that logs and continues, and the
creative-media side effect is not awaited at all. The `200` confirms the
payload was accepted and validated, nothing more. There is no storage
failure path that produces a `5xx`.

## Content-Type must be JSON

Only `application/json` is parsed. A `navigator.sendBeacon` call that
defaults to `text/plain` arrives with an empty body and is rejected as a
missing `cache_hit`. Construct the `Blob` with an explicit
`{ type: 'application/json' }`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordVastCacheMetric`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cache_hit` | `boolean` | yes | Whether the video was served from the local cache. Must be a real boolean. |
| `cache_usage_bytes` | `integer` |  | Total bytes the on-device cache currently holds. |
| `content_type` | `string` |  |  |
| `creative_id` | `string` |  | A leading `ima_` prefix is stripped before storage. |
| `device_class` | `string` |  | Defaults to `unknown`. |
| `device_id` | `string` |  |  |
| `error` | `string` |  | Fetch error string, when the attempt failed. |
| `eviction_triggered` | `boolean` |  | Whether storing this asset evicted another. Defaults to `false`. |
| `fetch_time_ms` | `integer` |  | Milliseconds spent fetching on a miss. Defaults to null. |
| `file_size_bytes` | `integer` |  | Size of the video asset. Defaults to `0`. |
| `fingerprint` | `string` |  |  |
| `media_url` | `string` |  | Asset URL. Also accepted as `mediaUrl`, but the camelCase spelling reaches only the ClickHouse mirror and the creative-media side effect — **not** the PostgreSQL row. Send `media_url` for complete attribution. |
| `request_id` | `string` |  |  |
| `screen_id` | `string` |  |  |
| `timestamp` | `string` |  | Defaults to the server clock. |
| `variant_name` | `string` |  | Defaults to `unknown`. |

Responses:

- `200` Metric accepted. The body is exactly `{"success": true}` — no id, no echo, and no confirmation of durable storage.
- `400` `cache_hit` is absent or is not a JSON boolean.

### Creative Review

Manual review and creative history endpoints

#### GET /openrtb/v1/creative-history/{screenId}

Get creative history for a screen

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeHistory`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` What played on this screen, newest first, with the content review attached to each creative. `review` is `null` for a creative that has never been classified — that is "not reviewed", not "reviewed and found clean". Branch on `null` before reading any classification flag. This is one of the few paginated operations on this surface, and it uses page numbers rather than a cursor: `pagination.pages` is the page count, not a page size.

#### GET /openrtb/v1/creative-review

List creative reviews

Paginated list of creative safety reviews — the records behind
`GET /openrtb/v1/creative-blocklist`. Each row carries the automated
content classification, the review status, and the manual-review trail.

Requires a bearer JWT whose `role` claim is `admin`. Note the unusual
status mapping on the way in: a missing token is `401`, an **invalid**
token is `403` (not `401`), and a valid non-admin token is also `403`
with a different body shape.

## Pagination is unguarded

`page` and `limit` are parsed but neither is clamped nor NaN-checked. A
non-numeric `page` produces a `500`. `limit` has no upper bound, so a
large value will attempt to materialise that many rows. `limit=0`
returns no rows and reports `pagination.pages` as JSON `null`, because
the computed value is infinite and does not survive serialisation.

`low_confidence` is a strict tri-state: exactly `true` or exactly
`false` filter; **anything else, including `1`, is silently ignored** and
you get the unfiltered list.

Rows are ordered by `last_seen_at` descending. Note that individual
records mix naming conventions — the id is `_id`, and timestamps are
`createdAt`/`updatedAt` among otherwise snake_case fields.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listCreativeReviews`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `string`: one of `pending`, `approved`, `flagged`, `blocked`, `metadata_only` |  | Review state. Not validated against the enum — an unrecognised value simply matches no rows rather than erroring. |
| `ad_system` | query | `string` |  | Ad system that served the creative. |
| `low_confidence` | query | `string`: one of `true`, `false` |  | Restrict to classifications the model was unsure about. Strict tri-state — see the note above. |
| `page` | query | `integer` |  | 1-based page number. Defaults to 1. A non-numeric value is a `500`. |
| `limit` | query | `integer` |  | Rows per page. Defaults to 50, with no upper bound enforced. See the note about `limit=0`. |

Responses:

- `200` One page of creative reviews.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` Either the bearer token was structurally invalid (`INVALID_TOKEN`, which arrives as 403 rather than 401) or the token authenticated a non-admin. The two cases have **different body shapes** — the first is `{ message, code }`, the second `{ statusCode, message }` with no `code` at all.
- `500` The query failed — including when `page` or `limit` could not be parsed as a number.

#### GET /openrtb/v1/creative-review/stats

Get creative review statistics

Whole-corpus counts for the creative review queue, plus the ten busiest
ad systems. Takes no parameters — it always reports on every review
record, with no date window.

Requires the same admin bearer JWT as `GET /openrtb/v1/creative-review`,
and the same unusual status mapping applies (an invalid token is `403`,
not `401`).

## The status buckets do not sum to `total`

`total` counts every review record, but only five of the six review
states have a bucket: rows in the `metadata_only` state are inside
`total` and inside no bucket. Compute a residual rather than assuming
`pending + approved + flagged + blocked` accounts for everything.

In `top_ad_systems` the ad system name is keyed **`_id`**, not
`ad_system`. The list is capped at ten entries and ordered by count
descending, so it is not a complete inventory of ad systems.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeReviewStats`

Responses:

- `200` Corpus-wide review statistics.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `403` Structurally invalid bearer token (`INVALID_TOKEN`, returned as 403) or a non-admin caller. The two cases have different body shapes — see `GET /openrtb/v1/creative-review`.
- `500` The query failed.

#### POST /openrtb/v1/creative-review/{creativeId}/block

Block creative

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `blockCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Request body (`application/json`): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | `string` |  | Free text stored with the block. Defaults to `Blocked by admin` when omitted; it is not validated and not an enum. |

Responses:

- `200` Creative blocked. `status` is the literal `blocked`; it echoes the action rather than re-reading stored state.

#### POST /openrtb/v1/creative-review/{creativeId}/confirm

Confirm or override AI classification

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `confirmCreativeReview`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | `string` |  |  |
| `reviewed_by` | `string` | yes | Who is confirming. **Required** — omitting it is a `400`. |
| `status_override` | `string`: one of `approved`, `flagged`, `blocked` |  | Replaces the AI classification. Any other value is a `400`. When omitted, the stored status is kept (falling back to `pending` if the creative has no review row yet). |

Responses:

- `200` Confirmation recorded. `manually_reviewed` is always `true` here — it marks that a human adjudicated the creative, and is what downstream serving checks to stop re-running the classifier.

#### POST /openrtb/v1/creative-review/{creativeId}/unblock

Unblock creative

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `unblockCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Responses:

- `200` Creative unblocked. Note the status is `approved`, not `unblocked` — unblocking restores the creative to the servable state, and the response names that state rather than the action. No request body is read.

### Creative Intelligence

Experimentation and creative performance analytics

#### GET /openrtb/v1/creative-intelligence/experiments

List creative experiments

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listCreativeExperiments`

Responses:

- `200` A page of experiments, newest first. **A backend failure is indistinguishable from an empty list here.** When the query fails, this operation still answers `200` with `experiments: []`, `total: 0`, `page: 1`, `limit: 20`, `pages: 0` — the same body a genuinely empty account produces. Do not treat an empty page as proof no experiments exist.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/creative-intelligence/experiments

Create creative experiment

Starts an A/B test across two or more creatives. The experiment begins
`running` immediately — there is no separate start call.

The response is **`201`**, and its `experiment` is a three-field
acknowledgement, not the full record. Read the experiment back with
`GET /openrtb/v1/creative-intelligence/experiments/{id}` if you need
the variants, allocation, or metric.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createCreativeExperiment`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `metric` | `string` |  | Decision metric. Governs which statistical test `POST .../analyze` runs — see that operation. |
| `name` | `string` | yes | Experiment label. Required. |
| `partner_id` | `string` |  | Defaults to the authenticated user when omitted. |
| `traffic_allocation` | `number` |  | Share of eligible traffic entering the test. Defaults to `1.0`. |
| `variants` | array of `object` | yes | At least two variants, each with a `creative_id`. Fewer than two, or any variant missing `creative_id`, is a `400`. |

Responses:

- `201` Experiment created and already `running`.
- `400` `name` missing, fewer than two variants, or a variant without a `creative_id`.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/creative-intelligence/experiments/{id}

Get creative experiment

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes |  |

Responses:

- `200` One experiment, plus per-variant results. `variant_results` is present **only on this operation** — the list and update operations return the experiment record without it. It is `[]` when the experiment has no plays yet, and also `[]` when the results query failed, which this response does not distinguish.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### PATCH /openrtb/v1/creative-intelligence/experiments/{id}

Pause, resume, or conclude creative experiment

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `updateCreativeExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes |  |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | `string`: one of `pause`, `resume`, `conclude` | yes | Transitions are checked: `pause` needs `running`, `resume` needs `paused`, `conclude` needs either. A legal action against the wrong current state is a `409`, not a `400`. |
| `winner_variant` | `string` |  | Read only when `action` is `conclude`. |

Responses:

- `200` The updated experiment record. **No `variant_results` here**, unlike `GET /openrtb/v1/creative-intelligence/experiments/{id}` — re-read the experiment if you need them. `conclude` is idempotent: concluding an already-completed experiment returns the existing record rather than erroring.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.
- `409` The action is not legal from the experiment's current state — for example pausing an experiment that is already `paused`.

#### POST /openrtb/v1/creative-intelligence/experiments/{id}/analyze

Run statistical analysis for experiment

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `analyzeCreativeExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes |  |

Responses:

- `200` Experiment analysis returned
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/creative-intelligence/insights/{creativeId}

Get creative insights

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeInsights`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Responses:

- `200` Creative insights returned
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/creative-intelligence/performance/{creativeId}

Get creative performance metrics

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativePerformance`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `creativeId` | path | `string` | yes |  |

Responses:

- `200` Aggregated delivery and engagement for one creative. **The array is the whole payload and it can legitimately be empty.** An unknown creative, a creative with no plays, and a backend that was unavailable all return `200` with `data: []`. This operation never distinguishes them, so an empty list is not evidence the creative does not exist. The row keys depend on `groupBy`: `screen_id` appears only when grouping by screen, `period` only when grouping by day or hour, and an ungrouped request returns a single row with neither. This payload also carries a windowed pre-aggregate attention column that the platform is retiring under its primitive-signal contract. It still works, it is deliberately not listed here, and a new integration should not be built on it.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### GET /openrtb/v1/creative-intelligence/top

Get top-performing creatives

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getTopCreatives`

Responses:

- `200` Leaderboard of creatives by composite score. `count` is the length of `creatives`, so an empty leaderboard is `count: 0` with a `200` — again not distinguishable from a backend that was unavailable. This payload also carries a windowed pre-aggregate attention column that the platform is retiring under its primitive-signal contract. It still works, it is deliberately not listed here, and a new integration should not be built on it.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

### OMSDK

OMSDK verification session and reporting routes

#### GET /openrtb/v1/omsdk/event

Record OMSDK event (GET image-beacon variant)

Beacon-friendly variant of `POST /openrtb/v1/omsdk/event` — the same
handler exposed as GET so a VAST player can fire OMSDK verification
events from an `<img>` / `navigator.sendBeacon` context where a request
body is not available. Event fields are passed as query parameters.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordOmsdkEventBeacon`

Responses:

- `200` Event recorded against the session. This GET shares its handler with the POST variant, so it returns **JSON, not a pixel** — unlike `GET /openrtb/v1/event`. It is fired from a VAST `<Tracking>` node where a JSON body is acceptable because no `Image()` load depends on it. `viewability` is `null` on most events. It is computed only when the arriving event is one that can settle a viewability verdict, so a `null` here means "not evaluated on this event", never "not viewable". `eventCount` is the running total for the session, so it increments across calls and is the cheapest way to confirm your events are landing on the session you think they are.

#### POST /openrtb/v1/omsdk/event

Record OMSDK event

Records one OMID measurement event, or a batch of them, against a session
opened by `POST /openrtb/v1/omsdk/session`.

## Single and batch forms behave differently on failure

Send one event as a bare object. Send several by wrapping them in
`batch`, up to 50 per request.

In the **single** form, a bad session or a bad event type is an HTTP
error: `404` when the session does not exist, `409` when it is already
completed, `400` for an unknown event type or malformed `data`.

In the **batch** form **none of those statuses can occur**. Per-item
failures are collected into an `errors` array under a `200`, so a batch
where every item failed still answers `200` with `processed: 0`. Always
read `failed` and `errors`; do not rely on the status code. Only two
things fail a batch as a whole: exceeding 50 items (`400`) and the
per-IP rate limit (`429`). The batch path also skips the per-session
rate limit and does not attach device or user-agent context.

The `errors` key is **omitted entirely** when nothing failed, rather than
being an empty array.

## Two events close the session

`complete` and `sessionFinish` mark the session completed. Every event
sent afterwards is rejected with `409`. Order your quartiles accordingly,
and send `sessionFinish` last.

## Rate limits

120 events per minute per session and 600 per minute per client IP, both
sliding windows. These are separate from the OpenRTB tracking limiter and
return their own body shape, shown under `429`.

A GET variant of this same operation exists for beacon contexts where no
request body is available.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `recordOmsdkEvent`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batch` | array of `object` |  | Up to 50 events. Each item is read for `sessionId`, `eventType`, and `data` only — any other key on an item is ignored. |
| `data` | `object` |  | Event payload. Must be a plain object — an array is rejected — and must serialise to at most 4096 bytes. |
| `deviceId` | `string` |  | Merged into `data` after validation. Falls back to the client IP. Single form only. |
| `eventType` | `string`: one of `sessionStart`, `sessionFinish`, `loaded`, `impression`, `geometryChange`, `start`, `firstQuartile`, `midpoint`, … |  | OMID event. Required in the single form. `complete` and `sessionFinish` close the session. |
| `sessionId` | `string` |  | Session id from the create call. Required in the single form. |

Responses:

- `200` Event recorded (single form), or the batch was processed. The two forms return **different body shapes** — the batch response has `processed`/`failed`/`results`, the single response has `eventType`/`recorded`/`eventCount`. A batch with failures also lands here. Read `failed` and `errors`.
- `400` Single form: `sessionId` or `eventType` missing, an unrecognised `eventType` (the message lists every valid value), or `data` that is not a plain object or exceeds 4096 bytes. Batch form: more than 50 items.
- `404` No such session — it never existed or has aged out of the one-hour window. **Single form only**; in a batch this becomes an `errors` entry under a `200`.
- `409` The session is already completed or expired, so it accepts no further events. **Single form only.**
- `429` Per-session (120/min) or per-IP (600/min) event limit exceeded. This body shape is specific to OMSDK and matches neither the tracking limiter nor the DSP limiter.
- `500` Unhandled server error. `message` echoes the raw internal exception.

#### GET /openrtb/v1/omsdk/report/{campaignId}

Get OMSDK verification report

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getOmsdkVerificationReport`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes |  |

Responses:

- `200` Campaign-level Open Measurement roll-up. **A campaign with no sessions returns a short body, not a zeroed full one.** In that case only `campaignId`, `queryPeriod`, `totalSessions: 0`, `viewabilityRate: 0`, `mrcComplianceRate: 0`, an empty `metrics` object and `generatedAt` are present — `completedSessions`, `screenBreakdown`, `omidVersion` and `partner` are all absent. Do not index into `metrics` without checking `totalSessions` first. `metrics.uniqueScreens` and `metrics.uniqueAds` are **counts**, not lists. `screenBreakdown` is capped at 100 screens and is best-effort — it degrades to an empty array rather than failing the request.
- `401` JWT bearer token missing, expired, or invalid. Emitted by the `verifyToken` middleware, which predates the canonical `{ error: { ... } }` envelope and returns a FLAT `{ message, code }` body. `code` is `NO_TOKEN` when no `Authorization` header was sent and `TOKEN_EXPIRED` for a well-formed but expired token. A structurally invalid token returns **403** with `INVALID_TOKEN`, not 401 — do not treat 403 on these routes as an authorization failure.

#### POST /openrtb/v1/omsdk/session

Create OMSDK session

Opens an Open Measurement (OMID 1.4) verification session for one ad
play. Everything else in the OMSDK flow hangs off the `sessionId` this
returns.

The response is **`201`**, not `200`. It also tells you where to go next:
`verificationScriptUrl` is the script to load into the player, and
`eventEndpoint` is where to post events — prefer both over hard-coding
paths, since the session id is already embedded in the script URL.

Sessions live for **one hour** in the hot store. After that, or once a
`complete` or `sessionFinish` event closes the session, further events
for that id are rejected — `404` if the session has aged out entirely,
`409` if it is present but already completed.

`adSessionType` is **not validated**: any string is accepted and stored.
It affects only the viewability thresholds, and the branch is a single
equality test against `display`, so every other value — including a typo
— is treated as video (50% of pixels for 2000 ms, against display's 100%
for 1000 ms).

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createOmsdkSession`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes | Advertisement being measured. The only required field. |
| `adSessionType` | `string`: one of `display`, `video`, `native` |  | Measurement profile. Only `display` changes behaviour; see the note above. Defaults to `video`. |
| `campaignId` | `string` |  |  |
| `displayContext` | `object` |  | Presentation the ad renders in. `mode`/`displayMode` and `areaRatio`/`displayAreaRatio` are both accepted spellings. |
| `impressionId` | `string` |  |  |
| `screenId` | `string` |  |  |
| `verificationVendors` | array of `string` |  | Third-party verification vendors to declare. Defaults to empty. |

Responses:

- `201` Session created. Retain `session.sessionId` — every subsequent event must carry it.
- `400` `adId` is missing.
- `500` Session creation failed. `message` echoes the raw internal exception; do not surface it to end users.

#### GET /openrtb/v1/omsdk/session/{sessionId}

DEPRECATED — returns 410 Gone

**Deprecated.**

This debug endpoint previously returned the full Mongo OMSDKSession document including
the `events[]` array. It had no production caller (verified via 14-day CloudWatch
Insights scan, 2026-04-30) and was removed as part of the OMSDK Mongo→ClickHouse
cutover (Phase 1b PR-2). Use ClickHouse-backed analytics endpoints for session/event
lookups going forward.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getOmsdkSessionStatus`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sessionId` | path | `string` | yes |  |

Responses:

- `410` Endpoint deprecated; see response body for migration guidance

#### GET /openrtb/v1/omsdk/verification.js

Get OMSDK verification script

Serves the JavaScript measurement agent a VAST player loads to drive an
OMID session. Do not construct this URL by hand — take it from
`session.verificationScriptUrl` in the create-session response, which
already carries the right `sid`.

Once loaded, the script fires `sessionStart`, `loaded`, and `impression`
back to back, then polls geometry once a second, emitting
`geometryChange`. It closes the session with `sessionFinish` on page
unload, or after a five-minute inactivity timeout, whichever comes first.
Events are posted to `POST /openrtb/v1/omsdk/event` via `sendBeacon`,
falling back to `XMLHttpRequest` and then `fetch` — one event is sent
bare, two or more are wrapped in `batch`.

It exposes `window.TrillboardsOMSDK` with `reportEvent`,
`reportGeometry`, `finishSession`, and `getGeometry`, so a player can
drive measurement manually rather than relying on the automatic
lifecycle.

## This endpoint has no error status

**Every response is `200`.** An unknown or expired `sid` is not an error:
the script is still served, with an empty session id baked in, and it
then silently declines to initialise — so measurement is off and nothing
on the wire says so. If session binding matters, verify it when you
create the session, not here.

A server-side failure is also `200`, returning a small stub that logs an
error to the browser console. Distinguish the two by the response
headers: the real script sends `Cache-Control: public, max-age=3600` and
an `X-OMID-Partner` header, while the stub sends `Cache-Control:
no-cache` and no `X-OMID-Partner`.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getOmsdkVerificationScript`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sid` | query | `string` |  | Session id to bind the script to. Optional, and an unknown value is not rejected — see above. Without a resolvable session the script loads and does nothing. |

Responses:

- `200` The verification script. Also the status for an unresolvable `sid` and for a server-side failure — check the headers to tell the real script from the error stub.

### Audience Intelligence

Lookalikes, prediction, semantic search, and creative ranking

#### GET /openrtb/v2/audience/lookalike/{screenId}

Find lookalike screens

Returns screens whose observed audience profile most resembles the
source screen's, for prospecting comparable inventory.

## Authentication

This is a DSP surface, not a portal one. It takes the same
**platform API key** as `GET /openrtb/v2/adslots` — as
`Authorization: Bearer <platform_api_key>` or an `x-api-key` header —
and the key must hold the `inventory:read` scope, which every onboarded
DSP key carries. A portal user's JWT is not accepted here. The route
shares the DSP inventory rate-limit budget.

- Auth: `dspBearerAuth` HTTP bearer or `dspApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `findLookalikeScreens`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` Matching screens, closest first. **An empty list is the answer to several different questions.** No stored vector for the source screen, no screen clearing `min_similarity`, an empty permitted-inventory set, and a backend failure all return `200` with `matches_count: 0`. This operation never distinguishes them, so do not read an empty result as "this screen has no lookalikes". `similarity` is cosine similarity in `0..1`, rounded to three decimals. It orders the list; it is not a probability and is not comparable across source screens.

#### GET /openrtb/v2/audience/predict/{screenId}

Predict audience for a screen and time window

Projects the audience a screen is likely to have in a given hour-of-day
and day-of-week slot, from its own history over `lookback_days`.

A screen with too little history is **not** an error: the call returns
`200` with `prediction: null` and a `message` saying so. Branch on
`prediction`, never on the status code.

## Authentication

This is a DSP surface, not a portal one. It takes the same
**platform API key** as `GET /openrtb/v2/adslots` — as
`Authorization: Bearer <platform_api_key>` or an `x-api-key` header —
and the key must hold the `inventory:read` scope, which every onboarded
DSP key carries. A portal user's JWT is not accepted here. The route
shares the DSP inventory rate-limit budget.

- Auth: `dspBearerAuth` HTTP bearer or `dspApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `predictAudience`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `hour` | query | `integer` | yes |  |
| `day` | query | `integer` | yes |  |
| `lookback_days` | query | `integer` |  |  |

Responses:

- `200` The prediction, or an explicit statement that there is not enough history to make one. **The two bodies do not have the same keys.** When `prediction` is `null`, `hour_utc` and `day_of_week` are omitted entirely and a `message` is added. Read `prediction` first. `confidence` is `sample_count / 30`, capped at `1` — it is a measure of how much history backs the estimate, not of how accurate the estimate is. A screen with 30 observations reports `1`.

#### POST /openrtb/v2/audience/rank-creatives

Rank creatives for an audience context

Ranks a candidate set of creatives by how closely each one's historical
play context resembles the audience you describe. Use it to pick which
creative to bid on a given slot with.

Scoring is vector similarity over observed audience contexts, not a
modelled conversion or revenue estimate. `predicted_score` is a cosine
similarity in `0..1` — a relative ordering signal, comparable **within**
one response and not across calls or across audiences.

## Sparse and failed results both return 200 with an empty list

A creative with no recorded play context is simply absent from the
result rather than scored zero, so `ranked_count` is routinely smaller
than the number of ids you sent. A backend failure is also reported as an
empty list under a `200`. An empty `creatives` array therefore means
"no ranking available", never "these creatives are a poor match" —
fall back to your own ordering rather than dropping the creatives.

`historical_plays` is the evidence count behind each score. Treat a score
backed by a handful of plays as weak.

This route requires the same DSP platform key as the inventory routes,
holding the `inventory:read` scope, and shares their 200-per-minute
budget.

- Auth: `dspBearerAuth` HTTP bearer or `dspApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `rankCreativesForAudience`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `audience` | `object` | yes | Audience context to rank against — the platform's live signal blob for a screen. Every key is optional, unrecognised keys are ignored, and missing signals are treated as zero, so a misspelled or sparse audience degrades the ranking silently rather than erroring. There is no validation to tell you that happened; compare `historical_plays` against what you expected if a ranking looks arbitrary. Stable keys worth sending: `dominantEmotion`, `incomeLevel`, `estimatedOccupancy`, `adReceptivityScore`, `emotionalEngagementScore`, `speechPurchaseIntent`, `inferredVenueType`, `venueSubtype`, `daypart`, `passerbyRatio`, `queueLength`, `vehicleCount`, `objectDiversityScore`, `temperatureF`, `isRaining`, `ambientLux`, `noiseTier`, `topLifestyleSegment`, `ageBucketDistribution`, `incomeTierDistribution`. The vectoriser also accepts several windowed pre-aggregate signal names that the platform is retiring under its primitive-signal contract. They still work, they are deliberately not listed here, and a new integration should not be built on them. Note that these names are **not** the ones `GET /openrtb/v2/adslots` publishes in its `fein` block — that surface renames them (`attention_score`, `live_face_count`, `crowd_density`, `dominant_emotion`). The two vocabularies do not round-trip, so map deliberately rather than passing an adslots `fein` object straight through. |
| `creative_ids` | array of `string` | yes | Candidate creatives to rank. Must be non-empty — an empty array is a `400`, not an empty result. |
| `limit` | `integer` |  | Maximum creatives to return, highest score first. Defaults to 5, capped at 50. |

Responses:

- `200` Ranking, best match first. Also returned — with an empty list — when no candidate has usable history or the backend was unavailable.
- `400` `audience` is missing, or `creative_ids` is missing or empty. Note that `param` reads `audience` in both cases — it does not identify which field was actually at fault.
- `401` Platform API key missing, unknown, or not linked to a DSP seat. The key is read from `Authorization: Bearer <key>`, `X-API-Key`, or `X-Trillboards-API-Key` (first match wins). **Two different bodies carry this status on the same route.** The authentication middleware raises the canonical `openrtb_auth_failed` error, shown in the example; the scope middleware's defensive branch emits the flat DSP envelope with `error_code: authentication_required`. Branch on the status code, and probe for `error` being an object versus a string before reading into it.
- `403` The key authenticated but is not entitled to this operation. **Two causes, two different body shapes.** A missing scope comes from the scope middleware as the flat DSP envelope with `error_code: missing_scope`, echoing `required_scope` and the `granted_scopes` the key actually holds — that is the example below, and it is the common case. A `suspended` or `revoked` DSP seat instead comes from the authentication middleware as the canonical envelope with `code: dsp_access_denied`. Every onboarded DSP key carries the default scope set, so a `missing_scope` here normally means the key was hand-scoped.
- `429` DSP inventory rate limit exceeded — 200 requests per rolling 60s window, bucketed per DSP seat (falling back to client IP for an unresolved seat). This route answers with the `{ success: false }` DSP error envelope, not the canonical one. Honour `Retry-After` and back off exponentially.

#### GET /openrtb/v2/audience/semantic-search

Semantic search across audience scene descriptions

Free-text search over embedded scene descriptions, returning screens
whose recent observed context best matches the query.

`min_similarity` filters on cosine distance, so raising it shrinks the
result set rather than reordering it. `since` bounds how far back the
observations may be drawn from.

## Authentication

This is a DSP surface, not a portal one. It takes the same
**platform API key** as `GET /openrtb/v2/adslots` — as
`Authorization: Bearer <platform_api_key>` or an `x-api-key` header —
and the key must hold the `inventory:read` scope, which every onboarded
DSP key carries. A portal user's JWT is not accepted here. The route
shares the DSP inventory rate-limit budget.

- Auth: `dspBearerAuth` HTTP bearer or `dspApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `semanticAudienceSearch`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | `string` | yes |  |
| `limit` | query | `integer` |  |  |
| `since` | query | `string` |  |  |
| `min_similarity` | query | `number` |  |  |

Responses:

- `200` Screens whose recent observed scenes best match the query. `time_window` echoes the `since` value you sent rather than resolving it to timestamps, and `query` echoes your trimmed search string. As with the other audience operations, an empty `results` list does not distinguish "no match" from "backend unavailable". Note `similarity` here is **not rounded**, unlike the lookalike operation's — expect full float precision.

#### GET /openrtb/v2/audience/semantic-stats

Get embedding statistics for semantic search

Coverage of the scene-embedding corpus that backs
`GET /openrtb/v2/audience/semantic-search`, over the **last 24 hours
only**. Use it to decide whether a semantic search is worth issuing
before you issue one.

Takes no parameters and no window can be requested.

## Read the counters as strings

`total_embeddings` and `unique_screens` are 64-bit database counters and
are serialised as **JSON strings** (`"14523"`, not `14523`). Coerce
before comparing or summing, and do not type them as integers in a
generated client or deserialisation will fail.

On an empty 24-hour window the two counts are the string `"0"` while
`earliest` and `latest` are `null`.

## `stats` can be null

When the backing store is unavailable the endpoint still returns `200`,
with `stats` set to `null` rather than an error. A `null` here means
"coverage unknown", not "no embeddings" — do not report it as zero
coverage.

- Auth: `dspBearerAuth` HTTP bearer or `dspApiKeyAuth` header `x-api-key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceSemanticStats`

Responses:

- `200` Embedding coverage for the last 24 hours, or `null` when the backing store could not be reached.
- `401` Platform API key missing, unknown, or not linked to a DSP seat. The key is read from `Authorization: Bearer <key>`, `X-API-Key`, or `X-Trillboards-API-Key` (first match wins). **Two different bodies carry this status on the same route.** The authentication middleware raises the canonical `openrtb_auth_failed` error, shown in the example; the scope middleware's defensive branch emits the flat DSP envelope with `error_code: authentication_required`. Branch on the status code, and probe for `error` being an object versus a string before reading into it.
- `403` The key authenticated but is not entitled to this operation. **Two causes, two different body shapes.** A missing scope comes from the scope middleware as the flat DSP envelope with `error_code: missing_scope`, echoing `required_scope` and the `granted_scopes` the key actually holds — that is the example below, and it is the common case. A `suspended` or `revoked` DSP seat instead comes from the authentication middleware as the canonical envelope with `code: dsp_access_denied`. Every onboarded DSP key carries the default scope set, so a `missing_scope` here normally means the key was hand-scoped.
- `429` DSP inventory rate limit exceeded — 200 requests per rolling 60s window, bucketed per DSP seat (falling back to client IP for an unresolved seat). This route answers with the `{ success: false }` DSP error envelope, not the canonical one. Honour `Retry-After` and back off exponentially.

### FEIN Analytics

FEIN edge AI analytics and readiness reporting

#### GET /openrtb/v2/fein/audience-segments

Get FEIN audience segments

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFeinAudienceSegments`

Responses:

- `200` FEIN audience segments
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/creative-venue-performance

Get creative x venue performance matrix

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeVenuePerformance`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `venue_type` | query | `string` |  |  |
| `days` | query | `integer` |  |  |
| `min_plays` | query | `integer` |  |  |
| `limit` | query | `integer` |  |  |

Responses:

- `200` Creative venue performance matrix
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/fill-rate-by-venue

Get fill rate by venue

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFillRateByVenue`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `venue_type` | query | `string` |  |  |
| `daypart` | query | `string` |  |  |
| `days` | query | `integer` |  |  |
| `limit` | query | `integer` |  |  |

Responses:

- `200` Fill rate by venue
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/inference-history

Get FEIN inference history

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFeinInferenceHistory`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screen_id` | query | `string` | yes |  |
| `start` | query | `string` |  | Start time (default 24h ago) |
| `end` | query | `string` |  | End time (default now) |
| `limit` | query | `integer` |  |  |
| `source` | query | `string`: one of `device`, `synthetic` |  | Filter by inference source |

Responses:

- `200` FEIN inference history
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/model-status

Get FEIN model status

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFeinModelStatus`

Responses:

- `200` FEIN model status
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/readiness

Get FEIN readiness report

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFeinReadiness`

Responses:

- `200` FEIN readiness report
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/screen-intelligence/{screenId}

Get screen intelligence

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenIntelligence`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` Screen intelligence data
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

### FEIN Webhooks

FEIN webhook registration and SSE streaming

#### GET /openrtb/v2/fein/stream

Stream FEIN events over SSE

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `streamFeinEvents`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `events` | query | `string` |  | Optional comma-separated event filter |

Responses:

- `200` SSE event stream
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/webhooks

List FEIN webhooks

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listFeinWebhooks`

Responses:

- `200` Every webhook registered against the calling API key, newest first. Scoped to that key — you cannot see another key's subscriptions. `failure_count` is cumulative delivery failures. A subscription is not deleted when it fails; watch `is_active` together with `last_delivered_at` to spot one that has gone quiet.
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### POST /openrtb/v2/fein/webhooks

Register FEIN webhook

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `registerFeinWebhook`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `events` | array of `string` | yes |  |
| `secret` | `string` |  |  |
| `url` | `string` | yes |  |

Responses:

- `201` FEIN webhook registered
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### DELETE /openrtb/v2/fein/webhooks/{id}

Delete FEIN webhook

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `deleteFeinWebhook`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `string` | yes |  |

Responses:

- `200` Subscription deleted. The body is exactly `{"deleted": true}` — the id is not echoed back. The delete is scoped to the calling API key, so deleting an id that exists but belongs to another key is a `404`, not a `403`. A `404` therefore means "not yours or not there" and does not confirm the id is unused.
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

### Content Intelligence

Content performance, recommendations, semantic search, and audience correlation

#### GET /openrtb/v2/fein/content-audience-correlation

Content category x audience demographic breakdown

Returns a breakdown of content categories crossed with venue types,
showing audience attention, face count, dwell time, and ad receptivity
for each combination. Useful for understanding which content types
drive the best audience engagement in which venue contexts.

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentAudienceCorrelation`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `content_category` | query | `string` |  | Optional content category filter |
| `days` | query | `integer` |  | Lookback window in days |
| `venue_type` | query | `string` |  | Optional venue type filter |

Responses:

- `200` Content audience correlation data
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/content-performance/{videoId}

Get content performance by video ID

Returns aggregated performance data for a single piece of content,
including play counts, audience attention metrics, venue distribution,
daypart distribution, and top-performing screens.

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentPerformance`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `videoId` | path | `string` | yes | YouTube video ID or stream identifier |
| `days` | query | `integer` |  | Lookback window in days |
| `venue_type` | query | `string` |  | Optional venue type filter |

Responses:

- `200` Content performance data
- `400` Missing or invalid videoId parameter
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### GET /openrtb/v2/fein/content-recommendations

Get best-performing content for a venue context

Returns content recommendations ranked by a composite performance score
(attention * 0.4 + replay density * 0.3 + dwell time * 0.3) for a given
venue type and optional daypart. Requires at least 3 plays to qualify.

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentRecommendations`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `venue_type` | query | `string` | yes | Venue type to get recommendations for |
| `daypart` | query | `string`: one of `morning`, `afternoon`, `evening`, `overnight` |  | Optional daypart filter |
| `limit` | query | `integer` |  |  |

Responses:

- `200` Content recommendations
- `400` Missing venue_type parameter
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

#### POST /openrtb/v2/fein/content-semantic-search

Semantic similarity search over content library

Accepts a natural language query, embeds it via the embedding service,
and returns content_reviews ordered by cosine similarity using pgvector.
Requires the content_reviews table to have embeddings populated.

- Auth: `apiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `contentSemanticSearch`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` |  |  |
| `minSimilarity` | `number` |  | Minimum cosine similarity threshold |
| `query` | `string` | yes | Natural language search query |

Responses:

- `200` Semantically similar content
- `400` Missing or too-short query
- `401` FEIN `X-API-Key` header missing, unknown, or revoked. Emitted by the FEIN `requireApiKey` middleware as a flat `{ error }` body — not the canonical envelope.

### Partner Exchange Bidder

SQL-owned OpenRTB bidder endpoint where Trillboards buys partner exchange inventory

#### POST /openrtb/bidder/{connectionAlias}/bid

Bid on partner exchange inventory

OpenRTB bidstream endpoint for offline-registered partner exchange connections.
This is not a platform API-key surface. The opaque `connectionAlias` resolves
`demand_partner_config.adapter_params.partner_exchange_bidder`, applies connection admission,
and answers through existing demand rails. SQL-controlled `initial_bid_mode=resale_waterfall`
maps inbound partner video/tag supply into `waterfallMediator.buildWaterfallOnly`; optional
`resale_waterfall_partner_keys` reuses the existing per-screen waterfall override shape.
Waterfall resale does not bid on a tag URL speculatively: explicitly allowed
partners are tried sequentially in the explicit configured order within one
shared inbound `tmax` budget. The first trusted VAST chain that returns a
playable, profitable inline creative is selected. The exact terminal
creative is returned in `adm` after inheriting impression, error, playback,
and click trackers from every proven wrapper, with one render-time settlement
beacon. Empty/unplayable responses and timeouts return no bid and create no
payable row.
Explicit `resale_openrtb_partner_keys` reuses existing DPC OpenRTB demand rows,
`OpenRTBAdapter`/`PrebidServerAdapter`, `BidCollector`, and the current OpenRTB
callback stores for `adm` passthrough across video, banner, audio, and native
impressions. Only the canonical `prebid_server` row selects PBS; PBS then uses
its existing bidder config or the row's `adapter_params.custom_bidders`.
The endpoint does not auto-discover every DPC OpenRTB endpoint.
A configured `resale_pricing_policy` is authoritative and fail-closed.
Commercial resale requires a real positive downstream bid plus injectable
VAST or banner HTML, explicit buyer/media/inventory identity scopes, and SQL-selected
downstream-minus or downstream-share pricing. The supply price must clear
the inbound floor and remain strictly below the rounded buyer bid. Auction
win is non-billable; every commercial bid returns `nurl` for auction
telemetry. Web banner `adm` contains the Trillboards render tracker and
omits outer `burl`. App banner uses the distinct `openrtb_burl`
settlement contract and returns a signed outer `burl` that supply must
fire only from its final-display accounting event. Video `adm` contains
the sole Trillboards VAST Impression and omits outer `burl`.
`settlement_event=standard_render` lets one connection derive those
concrete contracts from each accepted inventory/media pair while
preserving the same exact buyer, identity, seller-chain, and margin
gates.
A separately configured `resale_observation_policy` may call explicit
`observation_openrtb_partner_keys` under stable sampling and fail-closed
QPS limits to measure real buyer bid prices. The configured buyer keys
must exactly match the policy allowlist after case and duplicate
normalization; a mismatch is invalid and performs no buyer fanout.
Observation always returns HTTP 204, creates no `partner_bid_outcomes`
row, stores no buyer notice URL, and fires no `nurl`, `burl`, or `lurl`.
Configuring observation and revenue policy active at the same time fails
closed.
The current bidder evaluates `imp[0]`; send one impression per request.
Ad Ops may temporarily enable bounded sanitized floor diagnostics on this
same DPC connection. Those samples retain top-level/PMP/quantity fields and
exclude device IP/IFA; they do not change bidstream authentication or responses.
`initial_bid_mode=internal_vast` remains available for booked `partner_demand_line_items`
that should render ordinary Trillboards inline VAST from `advertisement_mongo_id`.
Successful bids include inline `adm` plus notice URLs. Waterfall-resale
video omits `burl` because the Trillboards VAST wrapper contains the sole
billable `imp` beacon. Safe-policy OpenRTB VAST and web-banner responses
also omit outer `burl` because their markup contains the settlement
tracker. App-banner `openrtb_burl` responses keep outer `burl` and do not
inject a financial markup-load pixel.
Legacy OpenRTB `adm` passthrough returns `burl` when upstream markup has no
Trillboards settlement beacon; it must fire after render. VAST and legacy
render-time callbacks may advance `partner_bid_outcomes.served_at`
directly. A safe-policy banner callback records `rendered_at`, releases
ordered buyer notices, and advances `served_at` only after billing HTTP
2xx is durably recorded.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `partnerExchangeBid`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `connectionAlias` | path | `string` | yes | Opaque partner-exchange connection alias. |

Request body (`application/json`, required): `BidRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `app` | `object` |  | App placement context |
| `device` | `object` |  |  |
| `dooh` | `object` |  | DOOH-specific placement context (OpenRTB 2.6) |
| `id` | `string` | yes | Unique bid request ID |
| `imp` | array of `object` | yes |  |
| `site` | `object` |  | Web placement context |
| `tmax` | `integer` |  | Maximum response time in ms |

Responses:

- `200` OpenRTB bid response with inline `adm` and notice URLs
- `204` No bid, unsupported payload, cap exceeded, no priced resale source, or no renderable cleared demand
- `401` Required request signature failed
- `403` Source IP is not allowed for this connection
- `404` Connection alias not found

#### GET /openrtb/bidder/{connectionAlias}/imp

Record billable partner exchange impression

Authenticated impression callback embedded inside creative markup `adm`
for VAST and site-banner resale. App-banner `openrtb_burl` bids return
this callback in the standard outer `burl`; supply must fire it only
from the final SDK/display accounting event, never auction win or
creative prefetch. Both banner contracts first record a non-payable
render receipt and release the PostgreSQL win-then-billing outbox.
`partner_bid_outcomes.served_at` advances only after durable downstream
notice delivery receives the required HTTP 2xx responses.
The outcome retains both supply and resale-demand lineage plus typed
supply dimensions for receivable reconciliation.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `partnerExchangeImpressionNotice`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `connectionAlias` | path | `string` | yes |  |
| `bid_request_id` | query | `string` | yes |  |
| `imp_id` | query | `string` | yes |  |
| `token` | query | `string` |  | Internal callback capability included exactly in the in-creative/VAST tracker or app-banner `burl`. |
| `sig` | query | `string` |  | HMAC signature for shared-secret notice connections. |

Responses:

- `200` Notice accepted. **The body is zero bytes** — no JSON, no `Content-Type`. Treat any 2xx as success. On a connection whose settlement policy names a creative impression or an OpenRTB `burl`, this is the beacon that settles the play and enqueues the downstream buyer notice. It is idempotent with respect to settlement: a repeat fire for an already-served outcome is accepted and does not re-settle.
- `204` The connection exists but is disabled. Zero-byte body.
- `401` Missing or invalid token/signature
- `404` Connection alias not found

#### GET /openrtb/bidder/{connectionAlias}/loss

Record partner exchange loss notice

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `partnerExchangeLossNotice`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `connectionAlias` | path | `string` | yes |  |
| `bid_request_id` | query | `string` | yes |  |
| `imp_id` | query | `string` | yes |  |
| `token` | query | `string` |  | Internal callback capability included exactly as returned in `lurl`. |
| `sig` | query | `string` |  | HMAC signature for shared-secret notice connections. |

Responses:

- `200` Notice accepted. **The body is zero bytes** — no JSON, no `Content-Type`. Treat any 2xx as success. Loss notices are recorded for auction diagnostics only. They never settle revenue and never suppress a later impression notice.
- `204` The connection exists but is disabled. Zero-byte body.
- `401` Missing or invalid token/signature
- `404` Connection alias not found

#### GET /openrtb/bidder/{connectionAlias}/spec

Get partner exchange OpenRTB connection spec

No-secret onboarding descriptor for an offline-registered partner exchange
connection. This is intentionally not a platform API-key surface; the alias
returns bid endpoints, accepted request objects/media types, self-contained
callback URL behavior, billable event semantics, user-sync support,
copy-pasteable OpenRTB request examples, and documentation links from
`demand_partner_config`. Returned bids also retain the inbound inventory
object, domain, app bundle, and country on `partner_bid_outcomes`; those
fields join the served settlement event to downstream demand-source
reconciliation without creating a parallel impression ledger. When a
configured resale pricing policy is dormant, the descriptor explicitly
reports HTTP 204-only behavior, no bid objects, and no billable event.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `partnerExchangeSpec`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `connectionAlias` | path | `string` | yes | Opaque partner-exchange connection alias. |

Responses:

- `200` Connection-specific bidstream contract
- `204` Connection exists but is not sandbox/active
- `404` Connection alias not found

#### GET /openrtb/bidder/{connectionAlias}/win

Record partner exchange win notice

Win callback generated in OpenRTB `nurl`. When the connection has no
shared notice secret, the returned URL is self-authenticating; when a
notice secret is configured, the returned URL carries `sig`. A win is
auction telemetry only and never creates a payable impression.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `partnerExchangeWinNotice`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `connectionAlias` | path | `string` | yes |  |
| `bid_request_id` | query | `string` | yes |  |
| `imp_id` | query | `string` | yes |  |
| `price` | query | `number` |  | Expanded `${AUCTION_PRICE}` CPM when supplied by the exchange. |
| `token` | query | `string` |  | Internal callback capability included exactly as returned in `nurl`. |
| `sig` | query | `string` |  | HMAC signature for shared-secret notice connections. |

Responses:

- `200` Notice accepted. **The body is zero bytes** — there is no JSON, no `Content-Type`, and therefore nothing to parse. Treat any 2xx as success and do not branch on the body. A win notice is auction telemetry and is **not** a billing event; settlement happens on the impression beacon or the render tracker, depending on the connection's configured policy. See `GET /openrtb/bidder/{connectionAlias}/spec` for which event settles on your connection.
- `204` The connection exists but is disabled. Also a zero-byte body. Stop firing notices for this alias until it is re-enabled.
- `401` Missing or invalid token/signature
- `404` Connection alias not found

### Fleet Management

#### POST /openrtb/v1/command-ack

Acknowledge a remote command issued to a screen

Screens POST here to acknowledge receipt and execution status of a
remote command pushed to them (refresh, sleep, restart, profile
update). Used by the fleet-management subsystem to mark commands
complete in the audit log.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `acknowledgeRemoteCommand`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `command_id` | `string` | yes |  |
| `detail` | `string` |  | Optional human-readable detail (failure reason, etc.) |
| `screen_id` | `string` | yes |  |
| `status` | `string`: one of `ack`, `success`, `failed`, `expired` | yes |  |

Responses:

- `200` Acknowledgement recorded
- `400` Missing required fields

### Internal

#### GET /openrtb

OpenRTB service root — endpoint catalogue

Returns a human-readable catalogue of the OpenRTB surface, grouped by
capability, with a one-line description per route.

It is a **convenience index for people**, not a machine contract: the
keys are `"<METHOD> <path>"` display strings with Express-style `:param`
placeholders, the groupings are hand-maintained, and nothing guarantees
the list stays in step with the routes actually mounted. Do not
discover endpoints from it — this specification is the contract.

`version` is the API major (`v1`), not a build number.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `openrtbServiceRoot`

Responses:

- `200` Endpoint catalogue.

#### GET /openrtb/health

OpenRTB service liveness probe

Liveness only.

**This probe checks no downstream dependency.** It does not touch
PostgreSQL, ClickHouse or Redis; it reports that the Node process is
answering and returns its own heap and uptime. A `200` here therefore
means the router is mounted and the event loop is turning, and says
nothing about whether an ad can actually be served. Do not use it as a
readiness gate for the serving path.

`status` at the top level is the literal `ok` on every response.
`memory.status` is the only field that varies with health: `healthy`
below 75% heap use, `warning` above 75%, `critical` above 90%. It is
computed against `heapTotal`, which Node grows on demand, so a high
percentage is not by itself evidence of a leak.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `openrtbHealth`

Responses:

- `200` The process is answering. This is the only status this operation returns — there is no dependency check that could fail it.

#### GET /openrtb/v2/openapi.json

Machine-readable OpenAPI 3 spec for the OpenRTB v2 surface

Returns the OpenAPI 3 JSON document describing the `/openrtb/v2/*`
endpoints (FEIN, audience reach, semantic search). Designed for
agentic clients that build typed SDKs on demand. The v1 surface
ships its spec under `/docs/openapi/programmatic-api.yaml` (this
file).

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getOpenRtbV2OpenApiSpec`

Responses:

- `200` OpenAPI 3 spec for OpenRTB v2

### Sandbox

#### GET /openrtb/v1/vast-test/{screenId}

Generate a test VAST response for a screen

Sandbox endpoint that returns a guaranteed-fill VAST response built
from a small set of test creatives. Used during partner integration
to validate that the screen's VAST parser handles the full set of
IAB-spec-required elements. The returned creative is NOT a billable
ad.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `testVastForScreen`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |

Responses:

- `200` Test VAST 4.2 XML document
