{"openapi":"3.0.3","info":{"title":"Trillboards DSP API","version":"1.0.0","description":"Programmatic buying API for Trillboards DOOH (Digital Out-of-Home) inventory.\n\n## Overview\n\nThe Trillboards DSP API lets demand-side platforms, agencies, and advertisers\nprogrammatically discover inventory, upload creatives, place bids, and serve ads on\nTrillboards screens worldwide — all via a single REST interface built on the\n[OpenRTB 2.6](https://iabtechlab.com/standards/openrtb/) protocol.\n\nUnlike traditional SSPs, Trillboards provides **FEIN (Face/Edge Intelligence Network)**\nsignals — live audience data from on-device edge AI including face count, gaze attention,\nemotion detection, and purchase intent. No other DOOH SSP provides these signals in the\nbid request.\n\n## Quick Start\n\nGet from zero to live ads in five API calls:\n\n```\n1. POST /openrtb/v2/onboard              → Register, get your API key (shown once!)\n2. GET  /openrtb/v2/adslots              → Browse live inventory with FEIN signals\n3. POST /openrtb/v2/creatives            → Upload a creative (AI moderation in ~30s)\n4. POST /openrtb/v2/campaigns            → Create a campaign with targeting\n5. POST /openrtb/v2/campaigns/assign     → Place on specific screens (live in 30-60s)\n```\n\nFor real-time bidding:\n\n```\n1. GET  /openrtb/v2/adslots              → Receive OpenRTB 2.6 bid requests\n2. POST /openrtb/v2/bid                  → Submit your bid response\n3. GET  /openrtb/v2/stats                → Track win rate and performance\n```\n\nFor direct IO booking (hold → confirm flow):\n\n```\n1. POST /openrtb/v2/screens/availability    → Check capacity for screens + dates\n2. POST /openrtb/v2/reservations            → Hold capacity (15-min TTL, shown once!)\n3. POST /openrtb/v2/reservations/:id/confirm → Convert held → confirmed placement\n```\n\n## Integration Guides\n\n### Availability → Reservation → Confirm\n\nUse this flow when buying guaranteed delivery on specific screens — for example, an agency holding inventory for a client before confirming the booking.\n\n**Step 1.** Query availability for candidate screens and a date range. The response tells you per-screen deliverable capacity, impressions already booked, and remaining availability. Use this to shortlist screens that can deliver your campaign.\n\n```bash\ncurl -X POST https://api.trillboards.com/openrtb/v2/screens/availability \\\n  -H \"x-api-key: tb_dsp_...\" \\\n\n  -H \"Content-Type: application/json\" \\\n\n  -d '{\n    \"screen_ids\": [\"65a1b2c3d4e5f6a7b8c9d0e1\", \"65a1b2c3d4e5f6a7b8c9d0e2\"],\n    \"start_date\": \"2026-05-01\",\n    \"end_date\": \"2026-05-14\",\n    \"granularity\": \"daily\"\n  }'\n\n```\n\n**Step 2.** Hold capacity for 15 minutes (default TTL) by creating a reservation. Each reservation is scoped to your DSP seat — other buyers cannot acquire the same impressions until the hold expires or you release it. A single reservation call can cover up to 10 screens at once; DSPs are capped at 10 active holds at a time.\n\n```bash\ncurl -X POST https://api.trillboards.com/openrtb/v2/reservations \\\n  -H \"x-api-key: tb_dsp_...\" \\\n\n  -H \"Content-Type: application/json\" \\\n\n  -d '{\n    \"screen_ids\": [\"65a1b2c3d4e5f6a7b8c9d0e1\"],\n    \"start_date\": \"2026-05-01\",\n    \"end_date\": \"2026-05-14\",\n    \"creative_id\": \"creative_summer_sale\",\n    \"campaign_name\": \"Summer Sale Launch\"\n  }'\n\n```\n\nThe response contains `reservation_id`, `expires_at`, and `ttl_seconds`. If you do not confirm within the TTL, the hold auto-expires and the capacity is released.\n\n**Step 3.** Before the TTL elapses, confirm the reservation to lock it in:\n\n```bash\ncurl -X POST https://api.trillboards.com/openrtb/v2/reservations/{reservation_id}/confirm \\\n  -H \"x-api-key: tb_dsp_...\"\n\n```\n\nConfirmation transitions the reservation from `held` to `confirmed`. Use `DELETE /openrtb/v2/reservations/{id}` to release a held reservation early.\n\n### Market Insights for Bidding\n\nBefore setting bid prices, query the market intelligence endpoints to calibrate CPM expectations. `GET /openrtb/v2/market/cpm-benchmarks` returns p25/median/p75 percentiles per venue type, and `GET /openrtb/v2/market/demand-analysis` surfaces top advertiser categories plus daypart demand curves so you know when other buyers are paying premium prices.\n\n```bash\n# Get CPM percentiles for coffee shops in the US over the last 30 days\ncurl \"https://api.trillboards.com/openrtb/v2/market/cpm-benchmarks?venue_type=coffee_shop&country=US&days=30\" \\\n  -H \"x-api-key: tb_dsp_...\"\n\n\n# Daypart curves + top categories so you know when to bid aggressively\ncurl \"https://api.trillboards.com/openrtb/v2/market/demand-analysis?days=30\" \\\n  -H \"x-api-key: tb_dsp_...\"\n\n```\n\nFor post-campaign verification, pair these with `GET /openrtb/v2/analytics/funnel` (VAST event funnel) and `GET /openrtb/v2/proof-of-play` (verified play records).\n\n## Authentication\n\nAll endpoints except `POST /openrtb/v2/onboard` require authentication.\nYou can authenticate using either method:\n\n**API Key Header (recommended):**\n```\nx-api-key: tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4\n```\n\n**Bearer Token:**\n```\nAuthorization: Bearer tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4\n```\n\nAPI keys are issued during onboarding and shown exactly once. Store them in a\nsecrets manager — they cannot be retrieved again.\n\n## Rate Limits\n\nRate limits are enforced per API key. Every response includes these headers:\n\n| Header | Description |\n|--------|-------------|\n| `RateLimit-Limit` | Maximum requests allowed in the current window |\n| `RateLimit-Remaining` | Requests remaining in the current window |\n| `RateLimit-Reset` | Seconds until the current window resets |\n| `Retry-After` | Seconds to wait before retrying (only on 429 responses) |\n\n**Limits by endpoint group:**\n\n| Endpoint Group | Sandbox Limit | Production Limit | Window |\n|----------------|---------------|------------------|--------|\n| Onboarding | 5 requests | 5 requests | 1 hour |\n| Bidding | 100 requests | 1,000 requests | 1 minute |\n| Inventory | 100 requests | 200 requests | 1 minute |\n| Creatives | 100 requests | 100 requests | 1 minute |\n| Campaigns | 100 requests | 100 requests | 1 minute |\n| Deals | 100 requests | 100 requests | 1 minute |\n| Reporting | 100 requests | 100 requests | 1 minute |\n\nEndpoint → group mapping for the new surfaces added in the current release:\n\n| Endpoint | Group |\n|----------|-------|\n| `GET /openrtb/v2/screens/:id` | Inventory |\n| `POST /openrtb/v2/screens/availability` | Inventory |\n| `POST /openrtb/v2/screens/audience` | Inventory |\n| `POST /openrtb/v2/reservations` | Campaigns |\n| `GET /openrtb/v2/reservations` | Campaigns |\n| `GET /openrtb/v2/reservations/:id` | Campaigns |\n| `POST /openrtb/v2/reservations/:id/confirm` | Campaigns |\n| `DELETE /openrtb/v2/reservations/:id` | Campaigns |\n| `POST /openrtb/v2/webhooks` | Campaigns |\n| `GET /openrtb/v2/webhooks` | Campaigns |\n| `GET /openrtb/v2/webhooks/deliveries` | Campaigns |\n| `DELETE /openrtb/v2/webhooks/:id` | Campaigns |\n| `GET /openrtb/v2/market/cpm-benchmarks` | Reporting |\n| `GET /openrtb/v2/market/demand-analysis` | Reporting |\n| `GET /openrtb/v2/analytics/funnel` | Reporting |\n| `GET /openrtb/v2/proof-of-play` | Reporting |\n\nWhen you exceed a rate limit, the API returns HTTP 429 with a `Retry-After` header\nindicating the number of seconds to wait. Implement exponential backoff in your\nintegration for production reliability.\n\n## Sandbox Mode\n\nNew DSPs start in **sandbox mode**. Sandbox provides:\n\n- Full API access to all endpoints\n- Bid validation and acceptance (bids are scored but do not enter live auction)\n- Reduced rate limits (100 requests/minute across all endpoints)\n- Real inventory data with FEIN signals for integration testing\n\nTo upgrade to production, contact **developers@trillboards.com** with:\n- Your seat ID\n- Expected daily bid volume\n- List of creative formats you will submit\n\n## AI Moderation Pipeline\n\nEvery creative uploaded via the API is automatically processed by **Gemini AI** for\ncontent safety and classification. The pipeline produces:\n\n| Output | Description |\n|--------|-------------|\n| **Content classification** | 8 boolean flags: alcohol, tobacco, gambling, political, violence, profanity, adult content, firearms |\n| **Age rating** | MPAA-style: G, PG, PG-13, R, NC-17 |\n| **Brand recognition** | Identifies known brands in creative content |\n| **IAB categories** | IAB Content Taxonomy 1.0 codes (IAB1 through IAB25 with subcategories) |\n| **Content analysis** | Subjects, setting, mood, quality assessment, and keyword extraction |\n| **Confidence score** | 0.0 to 1.0 indicating classification confidence |\n\nModeration typically completes within **30 seconds**. Poll `GET /openrtb/v2/creatives/{id}`\nto check status. Creatives in `pending_moderation` status cannot be used in campaigns.\n\n## FEIN Signals (Edge AI Audience Data)\n\nTrillboards screens are equipped with edge AI (FEIN — Face/Edge Intelligence Network)\nthat provides real-time audience measurement. The `/adslots` endpoint returns live\nsignals in each bid request's `ext.trb.fein` extension:\n\n| Signal | Type | Description | Update Frequency |\n|--------|------|-------------|-----------------|\n| `live_face_count` | integer | People currently facing the screen | Every 10 seconds |\n| `attention_score` | float (0-1) | Average gaze attention across visible faces | Every 10 seconds |\n| `dominant_emotion` | string | Most common emotion: happy, sad, angry, surprised, neutral, fear, disgust | Every 10 seconds |\n| `vas_7d` | float | Verified Attention Seconds — 7-day weighted rolling average | Hourly |\n| `crowd_density` | integer | Estimated total venue occupancy | Every 10 seconds |\n| `purchase_intent` | string | Speech-derived purchase intent level | Every 30 seconds |\n| `ad_receptivity` | float (0-1) | Predicted ad receptivity based on audience engagement | Every 30 seconds |\n| `income_level` | enum | Inferred income bracket: low, medium, high, premium | Hourly |\n| `dwell_time_ms` | integer | Average audience dwell time in milliseconds | Every 30 seconds |\n| `data_quality` | enum | Signal freshness: live, historical, or none | - |\n\nNot all screens have FEIN sensors. The `data_quality` field indicates:\n- **`live`** — Real-time FEIN data available (updated every 10-30 seconds)\n- **`historical`** — Only VAS historical data available (hourly updates)\n- **`none`** — No audience data for this screen (location/venue data only)\n\n## OpenRTB 2.6 Compliance\n\nThe bidding endpoint accepts standard OpenRTB 2.6 bid responses. Trillboards extensions\nare namespaced under `ext.trb` and are always optional. Your existing OpenRTB integration\nwill work without modification.\n\n## Webhooks\n\nTrillboards offers **two webhook systems**:\n\n**1. Win/loss notifications (legacy)** — configured once via `notification_url` during onboarding. Trillboards POSTs bid win and bid loss events to the URL you supplied. Payloads are signed with HMAC-SHA256 using your API key as the secret.\n\n**2. DSP webhook subscriptions (recommended)** — a managed subscription system exposed via `POST /openrtb/v2/webhooks`. Register one or more HTTPS URLs with a list of event types you care about (impression, campaign, reservation, screen lifecycle). Trillboards dispatches signed HTTP POSTs as those events occur. Each webhook returns a unique `whsec_...` secret **once** at creation, used to verify the `X-Trillboards-Signature` header on delivery. DSPs can register up to **10 active webhooks per seat**.\n\n**Supported events** (as of this release):\n\n| Event | When it fires |\n|-------|---------------|\n| `impression.delivered` | `impression_callback` fires — pixel hit reconciles with partner counts |\n| `impression.verified` | VAST `complete` or signed proof_of_play arrives |\n| `campaign.allocated` | Creative has been assigned to screens, delivery is staged |\n| `campaign.started` | First impression of a campaign has been served |\n| `campaign.completed` | Campaign reached goal impressions or end_date |\n| `creative.moderation.completed` | AI moderation reached terminal status (approved | rejected | needs_review) for a creative your seat submitted |\n| `reservation.confirmed` | A held reservation was confirmed → converted to placement |\n| `reservation.expired` | A held reservation hit its TTL without confirmation |\n| `screen.online` | A screen came back online after being down |\n| `screen.offline` | A screen stopped heartbeating |\n\n**Delivery payload shape:**\n\n```http\nPOST https://your-webhook-url\nContent-Type: application/json\nX-Trillboards-Event: impression.delivered\nX-Trillboards-Delivery-Id: del_01HS8A...\nX-Trillboards-Signature: sha256=<HMAC_SHA256(secret, body)>\n\n{  \"event\": \"impression.delivered\",\n  \"delivered_at\": \"2026-05-01T14:22:31.000Z\",\n  \"data\": { ... event-specific payload ... }\n}\n```\n\n**Signature verification (Node.js example):**\n\n```js\nconst crypto = require('crypto');\nconst expected = 'sha256=' + crypto  .createHmac('sha256', process.env.TRILLBOARDS_WEBHOOK_SECRET)\n  .update(requestBody)\n  .digest('hex');\n\nif (!crypto.timingSafeEqual(  Buffer.from(expected),\n  Buffer.from(request.headers['x-trillboards-signature'])\n)) {  return res.status(401).end();\n}\n```\n\nSee the `DSP Webhooks` section below for the full subscription API, including registration, listing, deletion, and delivery log endpoints.\n\n## Error Handling\n\nAll error responses follow a consistent format:\n\n```json\n{  \"success\": false,\n  \"error\": \"Human-readable error description\"\n}\n```\n\nStandard HTTP status codes are used:\n\n| Code | Meaning |\n|------|---------|\n| 200 | Success |\n| 201 | Resource created |\n| 400 | Invalid request parameters |\n| 401 | Missing or invalid API key |\n| 403 | Insufficient permissions (e.g., sandbox DSP attempting production action) |\n| 404 | Resource not found |\n| 409 | Conflict (e.g., duplicate seat ID) |\n| 415 | Unsupported media type |\n| 422 | Validation error (e.g., creative not yet approved) |\n| 429 | Rate limit exceeded |\n| 500 | Internal server error |\n","contact":{"name":"Trillboards Developer Support","email":"developers@trillboards.com","url":"https://trillboards.com/developers"},"license":{"name":"Proprietary","url":"https://trillboards.com/terms-of-service"}},"servers":[{"url":"https://api.trillboards.com/openrtb/v2","description":"Production"},{"url":"http://localhost:4004/openrtb/v2","description":"Local development"}],"tags":[{"name":"Onboarding","description":"Self-serve DSP registration and API key management.\n\nRegister in a single API call — no manual approval required. You receive an API key\nimmediately and start in sandbox mode with full API access. Sandbox bids are validated\nand scored but do not enter the live auction, so you can integrate safely.\n\n**Lifecycle:** sandbox -> approved -> (suspended | revoked)\n\nTo upgrade from sandbox to production, contact developers@trillboards.com with your\nseat ID and expected daily bid volume. Production DSPs get 10x higher rate limits and\ntheir bids enter the live waterfall auction.\n"},{"name":"Inventory","description":"Real-time DOOH inventory discovery with FEIN edge AI signals.\n\nBrowse available screens as OpenRTB 2.6 bid request objects, each enriched with live\naudience data from on-device computer vision and audio analysis. No other SSP provides\nreal-time face count, gaze attention, emotion detection, and purchase intent signals\ndirectly in the bid request.\n\n**Three discovery modes:**\n- **`GET /adslots`** — Structured filtering by venue type, geography, and FEIN thresholds\n- **`POST /discover`** — Semantic search using natural language (powered by pgvector embeddings)\n- **`POST /forecast`** — Budget-based delivery estimation using 7-day ClickHouse historical data\n"},{"name":"Bidding","description":"OpenRTB 2.6 real-time bidding.\n\nSubmit standard OpenRTB 2.6 bid responses. Each bid must include an impression ID (`impid`),\na price in CPM, and either inline ad markup (`adm`) or a win notice URL (`nurl`).\n\nBids are validated against creative blocklists and floor prices, then stored for the\nlive waterfall auction. The response includes per-bid acceptance or rejection with\nspecific reasons (below_floor, creative_blocked, missing_adm_and_nurl).\n\n**Win/loss notifications** are sent to your `notification_url` (if configured during\nonboarding) as POST requests with the bid ID, win price, and screen details.\n"},{"name":"Creatives","description":"Creative upload with automatic Gemini AI moderation.\n\nUpload creatives via URL reference or presigned S3 upload. Every creative is automatically\nprocessed by the Gemini AI moderation pipeline, which produces:\n\n- **Content classification** — 8 safety flags (alcohol, tobacco, gambling, etc.)\n- **Age rating** — G, PG, PG-13, R, NC-17\n- **Brand detection** — Identifies known brands in creative content\n- **IAB categories** — IAB Content Taxonomy 1.0 codes for programmatic targeting\n- **Content analysis** — Subjects, setting, mood, quality, keywords\n\nModeration completes in approximately 30 seconds. Poll `GET /creatives/{id}` to check\nstatus. Only `approved` creatives can be used in campaigns.\n"},{"name":"Campaigns","description":"Campaign creation and screen assignment.\n\nCreate campaigns by linking approved creatives to targeting criteria. The API returns\nmatching screens based on your targeting, then you assign the creative to specific\nscreens. Screens pick up new assignments within their content refresh cycle\n(approximately 30-60 seconds).\n\n**Workflow:**\n1. `POST /campaigns` — Create campaign with targeting, get matching screens\n2. `POST /campaigns/assign` — Assign creative to specific screen IDs\n3. Screens begin showing the creative within 30-60 seconds\n"},{"name":"Deals","description":"Private marketplace (PMP) and preferred deal management.\n\nBrowse active deals available to your DSP seat, view deal details, and propose new\ndeals with custom floor CPMs, impression caps, and venue/geography targeting.\n\n**Deal types:**\n- **PMP** — Private marketplace with floor price, open to invited buyers\n- **Preferred** — Preferred deal with fixed price, priority over open auction\n- **Guaranteed** — Guaranteed delivery with committed volume\n\nProposed deals require admin approval and transition from `pending` to `active` status.\n"},{"name":"Reporting","description":"ClickHouse-backed analytics and reporting.\n\nAll reporting endpoints query ClickHouse materialized views for sub-second response\ntimes across billions of impression events. Three report types are available:\n\n- **Summary** — Aggregated totals: impressions, unique screens, CPM, fill rate\n- **Per-screen** — Breakdown by screen: impressions, CPM, fills, hours active\n- **Time-series** — Chronological data points at hourly or daily granularity\n\nReports cover a configurable lookback period (default 7 days, maximum 90 days).\n"},{"name":"Inventory Discovery","description":"Universal inventory discovery API for querying the full Trillboards screen network.\n\nThe `/inventory/v1/screens` endpoint provides a structured, filterable view of all\nauction-enabled screens with location, venue, display, device, programmatic, and\ncontent-rule metadata. Results are cached at the edge with `X-Cache: HIT/MISS`\nheaders for efficient polling.\n\n**Use this endpoint to:**\n- Build a screen catalog for media planning\n- Filter by geography, venue type, floor CPM range, and online status\n- Retrieve display specs, device capabilities, and content restrictions\n- Paginate through large inventories with `limit` and `offset`\n"},{"name":"Supply Chain","description":"IAB supply chain transparency endpoints.\n\nTrillboards publishes standard IAB sellers.json and ads.txt files for supply chain\nverification. These endpoints are public (no authentication required) and cached\nfor 24 hours.\n\n- **`/sellers.json`** — IAB OpenRTB sellers.json listing all authorized sellers\n- **`/ads.txt`** — Redirects to `/app-ads.txt` (canonical source)\n- **`/app-ads.txt`** — Authorized digital sellers declarations\n"},{"name":"Screens","description":"Deep screen intelligence — detail profile, capacity forecasting, and audience profiles.\n\nThese endpoints support direct IO booking, pre-auction targeting, and pre-bid\ndecisioning. Unlike `/adslots` (which returns OpenRTB bid requests), these surfaces\nare purpose-built for structured screen-by-screen queries.\n\n- **`GET /screens/:id`** — Full profile: physical size, spot config, venue intelligence,\n  VAS scoring, performance rollups, reliability, device capabilities, brand safety.\n- **`POST /screens/availability`** — Bulk capacity forecast + booked impressions\n  for up to 50 screens and an arbitrary date range. Returns deliverable capacity,\n  reservations, and remaining availability per screen.\n- **`POST /screens/audience`** — Bulk audience profiles for up to 50 screens,\n  combining live Redis signals (data_quality=live) with VAS rolling averages\n  (data_quality=historical).\n"},{"name":"Reservations","description":"Hold → Confirm → Release flow for direct IO booking.\n\nReservations let you lock down screen capacity for a configurable TTL (15 min by\ndefault) before confirming the final booking. Pairs with\n`POST /screens/availability` for pre-flight capacity checks.\n\n**Lifecycle:** `held -> confirmed | expired | released`\n\n- **`POST /reservations`** — Hold capacity on up to 10 screens at once.\n- **`GET /reservations`** — List reservations for your DSP seat (auto-expires stale holds on read).\n- **`GET /reservations/:id`** — Fetch a single reservation, including live TTL countdown.\n- **`POST /reservations/:id/confirm`** — Convert a held reservation to a placement.\n- **`DELETE /reservations/:id`** — Release a held reservation, freeing capacity immediately.\n\n**Limits:** Max 10 screens per request, max 10 active reservations per DSP seat.\n"},{"name":"Market Intelligence","description":"CPM benchmarks, demand curves, and segment premium analysis for pre-bid pricing.\n\nData is sourced from PG rollup tables (refreshed every 5 minutes from ClickHouse\nmaterialized views). Queries return in <100 ms and cover configurable lookback\nwindows up to 365 days.\n\n- **`GET /market/cpm-benchmarks`** — p25/median/p75/avg CPM percentiles by venue\n  type, country, state, and daypart. Includes a market overview with total\n  SSP-enabled screens, average fill rate, and average completion rate.\n- **`GET /market/demand-analysis`** — Top advertiser categories by bid volume,\n  daypart demand curves (avg CPM + fill rate by morning/afternoon/evening/late_night),\n  and segment CPM premium lift over baseline.\n"},{"name":"Analytics","description":"VAST event funnel — from ad requests to completed plays.\n\nReturns a request → bid_received → render_started → play_completed funnel with\nbid rate, fill rate, and start-to-complete rate. Supports per-screen breakdown\n(top 50) and daily/hourly/flight timeseries.\n\n**Data source:** `openrtb_vast_screen_hourly` PG rollup table (OLTP-safe, refreshed\nevery 5 minutes from ClickHouse). Cached in memory for 5 minutes per query\nfingerprint.\n"},{"name":"Proof of Play","description":"Verified ad play records for reconciliation and billing audit.\n\nReturns rows from the `completed_impressions` table (partitioned monthly on\n`completed_at`) with verification tier mapping:\n\n- **`signed`** — Ed25519 signed, screen-side proof-of-play confirmation\n- **`event_confirmed`** — VAST `complete` event fired by the IMA SDK\n- **`impression_callback`** — Impression pixel fired by the screen\n\nAll queries must include `start_date` and `end_date` (required for partition\npruning). Responses include per-tier counts, GIVT pass rate, and paginated\nproof records.\n"},{"name":"DSP Webhooks","description":"Managed webhook subscription system for real-time event notifications.\n\nRegister HTTPS endpoints with a filtered list of event types. Trillboards\ndispatches signed HTTPS POSTs as events occur across the ad lifecycle (impression,\ncampaign, reservation, screen). Each webhook receives a unique `whsec_...` secret\nat creation — **shown exactly once** — that must be used to verify the\n`X-Trillboards-Signature` header on delivery.\n\n- **`POST /webhooks`** — Register a webhook subscription (URL + events).\n- **`GET /webhooks`** — List all webhooks for your DSP seat (without secrets).\n- **`GET /webhooks/deliveries`** — Self-serve delivery log (recent attempts + failures).\n- **`DELETE /webhooks/:id`** — Remove a webhook subscription. Ownership verified.\n\n**Limits:** Max 10 active webhooks per DSP seat. URL must use HTTPS. Only events\nfrom the supported event list are accepted.\n"}],"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}],"paths":{"/onboard":{"post":{"operationId":"onboard","summary":"Register a new DSP","description":"Self-serve DSP registration. Returns an API key that is **shown exactly once** —\nstore it in a secrets manager immediately. New DSPs start in sandbox mode with\nreduced rate limits (100 requests/minute).\n\n**Sandbox mode** provides full API access for integration testing. Bids are validated\nand scored but do not enter the live waterfall auction. Contact\ndevelopers@trillboards.com to upgrade to production.\n\nThe `seat_id` is your permanent OpenRTB buyer seat identifier. It must be unique\nacross all registered DSPs and cannot be changed after registration. Use a short,\nmemorable identifier like your company abbreviation.\n\n**Rate limit:** 5 requests per hour (applies to both successful and failed attempts).\n","tags":["Onboarding"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["company_name","seat_id","contact_email"],"properties":{"company_name":{"type":"string","description":"Your company or organization name. This appears in auction logs\nand reporting dashboards. Use your legal entity name.\n","minLength":2,"maxLength":200,"example":"Acme Advertising"},"seat_id":{"type":"string","description":"Unique OpenRTB buyer seat identifier. Must be alphanumeric with\nhyphens and underscores only, between 2 and 64 characters.\nThis becomes your permanent identifier in all bid requests and\nauction logs. Cannot be changed after registration.\n","pattern":"^[a-zA-Z0-9_-]{2,64}$","minLength":2,"maxLength":64,"example":"acme_ads"},"contact_email":{"type":"string","format":"email","description":"Primary technical contact email. Used for API key recovery,\nsandbox-to-production upgrade notifications, and integration\nsupport. Must be a valid email address.\n","example":"buyer@acme-ads.com"},"billing_email":{"type":"string","format":"email","description":"Billing contact email for invoices and payment notifications.\nIf not provided, billing communications go to the contact_email.\n","example":"billing@acme-ads.com"},"openrtb_version":{"type":"string","description":"OpenRTB protocol version your DSP supports. Currently only\nversion 2.6 is supported. Defaults to \"2.6\" if not specified.\n","default":"2.6","enum":["2.5","2.6"],"example":"2.6"},"notification_url":{"type":"string","format":"uri","description":"HTTPS endpoint for win/loss notification webhooks. When your bid\nwins or loses an auction, a POST request is sent to this URL with\nthe bid ID, clearing price, and screen details. Must be HTTPS.\n","example":"https://acme-ads.com/webhooks/trillboards/notifications"},"webhook_url":{"type":"string","format":"uri","description":"HTTPS endpoint for general event webhooks (creative moderation\nresults, deal status changes, campaign updates). Must be HTTPS.\n","example":"https://acme-ads.com/webhooks/trillboards/events"}}},"example":{"company_name":"Acme Advertising","seat_id":"acme_ads","contact_email":"buyer@acme-ads.com","billing_email":"billing@acme-ads.com","openrtb_version":"2.6","notification_url":"https://acme-ads.com/webhooks/trillboards/notifications","webhook_url":"https://acme-ads.com/webhooks/trillboards/events"}}}},"responses":{"201":{"description":"DSP registered successfully. The API key in the response is shown **exactly once**.\nStore it immediately in a secure location. If lost, you must contact support\nfor a key rotation.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":5}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":4}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":3540}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the registration was successful","example":true},"data":{"type":"object","description":"Registration result containing credentials and sandbox instructions","properties":{"registration":{"type":"object","description":"Your DSP registration record","properties":{"id":{"type":"string","format":"uuid","description":"Unique registration ID for support reference","example":"a3f8c9e1-7b42-4d6a-9e3f-1c8b5d2a7f04"},"seat_id":{"type":"string","description":"Your permanent OpenRTB buyer seat identifier","example":"acme_ads"},"company_name":{"type":"string","description":"Registered company name","example":"Acme Advertising"},"status":{"type":"string","description":"Current DSP status. All new registrations start as \"sandbox\".\nContact support to upgrade to \"approved\" for production access.\n","enum":["sandbox","approved","suspended","revoked"],"example":"sandbox"},"openrtb_version":{"type":"string","description":"OpenRTB protocol version configured for this DSP","example":"2.6"},"created_at":{"type":"string","format":"date-time","description":"Registration timestamp in ISO 8601 format","example":"2026-04-03T14:22:31.000Z"}}},"api_key":{"type":"object","description":"**CRITICAL: Store this securely — the full key is shown exactly once\nand cannot be retrieved again.** If lost, contact support for key rotation.\n","properties":{"key":{"type":"string","description":"Full API key. Use this in the `x-api-key` header or as a Bearer token.\nThis value is never returned again by any endpoint. Store it in a\nsecrets manager immediately.\n","example":"tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4"},"prefix":{"type":"string","description":"Key prefix for identification. This is the only portion visible\nin logs and admin interfaces after initial display.\n","example":"tb_dsp_a1b2c"},"scopes":{"type":"array","description":"Permissions granted to this API key. Sandbox keys receive all\nscopes but rate limits are lower.\n","items":{"type":"string","enum":["bid:write","inventory:read","deals:read","stats:read","campaigns:write","reports:read","proof:read"]},"example":["bid:write","inventory:read","deals:read","stats:read","campaigns:write","reports:read","proof:read"]},"rate_limit_rpm":{"type":"integer","description":"Maximum requests per minute for this API key. Sandbox keys\nare limited to 100 RPM. Production keys get 1,000 RPM.\n","example":100}}},"sandbox":{"type":"object","description":"Sandbox environment details and getting-started instructions","properties":{"description":{"type":"string","description":"Human-readable explanation of sandbox mode","example":"Your DSP is in sandbox mode. Bids are validated and scored but do not enter the live auction. Contact developers@trillboards.com to upgrade to production."},"rate_limit":{"type":"string","description":"Rate limit summary for sandbox mode","example":"100 requests/min (upgraded to 1000 on approval)"},"endpoints":{"type":"object","description":"Key endpoints to start integrating with","properties":{"bid":{"type":"string","description":"Bid submission endpoint","example":"POST /openrtb/v2/bid"},"adslots":{"type":"string","description":"Inventory discovery endpoint","example":"GET /openrtb/v2/adslots"},"stats":{"type":"string","description":"Performance metrics endpoint","example":"GET /openrtb/v2/stats"}}},"auth":{"type":"string","description":"Authentication instructions","example":"Include x-api-key header with your API key in all authenticated requests"}}}}}}},"example":{"success":true,"data":{"registration":{"id":"a3f8c9e1-7b42-4d6a-9e3f-1c8b5d2a7f04","seat_id":"acme_ads","company_name":"Acme Advertising","status":"sandbox","openrtb_version":"2.6","created_at":"2026-04-03T14:22:31.000Z"},"api_key":{"key":"tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4","prefix":"tb_dsp_a1b2c","scopes":["bid:write","inventory:read","deals:read","stats:read","campaigns:write","reports:read","proof:read"],"rate_limit_rpm":100},"sandbox":{"description":"Your DSP is in sandbox mode. Bids are validated and scored but do not enter the live auction. Contact developers@trillboards.com to upgrade to production.","rate_limit":"100 requests/min (upgraded to 1000 on approval)","endpoints":{"bid":"POST /openrtb/v2/bid","adslots":"GET /openrtb/v2/adslots","stats":"GET /openrtb/v2/stats"},"auth":"Include x-api-key header with your API key in all authenticated requests"}}}}}},"400":{"description":"Invalid request parameters. One or more required fields are missing or malformed.\nCheck the error message for specific field validation failures.\n","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Always false for error responses","example":false},"error":{"type":"string","description":"Human-readable description of what went wrong","example":"company_name, seat_id, and contact_email are required"}}},"example":{"success":false,"error":"company_name, seat_id, and contact_email are required"}}}},"409":{"description":"Seat ID conflict. The requested `seat_id` is already registered by another DSP.\nChoose a different seat ID. Seat IDs from suspended or revoked DSPs are also\nunavailable.\n","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Always false for error responses","example":false},"error":{"type":"string","description":"Conflict description including the existing registration status","example":"Seat ID \"acme_ads\" is already registered (status: sandbox)"}}},"example":{"success":false,"error":"Seat ID \"acme_ads\" is already registered (status: sandbox)"}}}},"415":{"description":"Unsupported media type. The request Content-Type header must be `application/json`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Content-Type must be application/json"}}}},"429":{"description":"Rate limit exceeded. Onboarding is limited to 5 requests per hour per IP address.\nWait for the period indicated by the `Retry-After` header.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":5}},"RateLimit-Remaining":{"description":"Requests remaining (always 0 when rate limited)","schema":{"type":"integer","example":0}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":2847}},"Retry-After":{"description":"Seconds to wait before retrying","schema":{"type":"integer","example":2847}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"Rate limit exceeded. Maximum 5 onboarding requests per hour."},"retry_after":{"type":"integer","description":"Seconds to wait before retrying","example":2847}}},"example":{"success":false,"error":"Rate limit exceeded. Maximum 5 onboarding requests per hour.","retry_after":2847}}}},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/adslots":{"get":{"operationId":"getAdSlots","summary":"Browse available inventory with FEIN signals","description":"Returns OpenRTB 2.6 bid request objects for all active screens currently seeking\nprogrammatic demand. Each bid request includes live FEIN (Face/Edge Intelligence\nNetwork) signals in the `ext.trb.fein` extension when available.\n\n**This is the primary inventory discovery endpoint.** Use it to:\n- Browse all available screens with real-time audience data\n- Filter by geography, venue type, and audience signals\n- Get ready-to-use OpenRTB 2.6 bid request objects for your bidder\n\n**FEIN signal filtering:**\n- `min_vas=5.0` — Only screens with 5+ Verified Attention Seconds (7-day average)\n- `min_attention=60` — Only screens where 60%+ of detected faces are paying attention\n- `emotion=happy` — Only screens where the dominant audience emotion is \"happy\"\n\nFilters are combined with AND logic. Screens that lack FEIN sensors are excluded\nwhen FEIN filters are applied.\n\n**Signal coverage** is reported in the response. Not all screens have FEIN sensors:\n- `total_screens` — Total screens matching geographic/venue filters\n- `fein_enriched` — How many of those have live or historical audience data\n- `after_filters` — How many remain after applying FEIN signal filters\n","tags":["Inventory"],"parameters":[{"name":"format","in":"query","required":false,"description":"Response format. Determines the shape of the response body.\n- `screens` (default) — Returns `{ total, screens, fein_coverage }` with\n  screen summaries and FEIN sensor coverage stats.\n- `openrtb` — Returns `{ available_slots, bid_requests }` with full\n  OpenRTB 2.6 bid request objects suitable for programmatic bidding.\n","schema":{"type":"string","enum":["openrtb","screens"],"default":"screens"},"example":"openrtb"},{"name":"venue_type","in":"query","required":false,"description":"Filter by venue taxonomy. Common values: retail, restaurant, bar, gym,\nhotel_lobby, transit, office, hospital, salon, gas_station, laundromat,\nairport, university, coworking. Case-insensitive.\n","schema":{"type":"string"},"example":"retail"},{"name":"country","in":"query","required":false,"description":"ISO 3166-1 alpha-2 country code. Filters screens to the specified country.\n","schema":{"type":"string","minLength":2,"maxLength":2},"example":"US"},{"name":"state","in":"query","required":false,"description":"State or province code (e.g., \"CA\" for California, \"ON\" for Ontario).\nRequires `country` parameter for accurate results.\n","schema":{"type":"string"},"example":"NY"},{"name":"city","in":"query","required":false,"description":"City name filter. Case-insensitive partial match.\n","schema":{"type":"string"},"example":"New York"},{"name":"limit","in":"query","required":false,"description":"Maximum number of screens to return. Default is 50. Maximum is 200.\nResults are ordered by FEIN signal quality (live > historical > none),\nthen by VAS score descending.\n","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50},{"name":"min_vas","in":"query","required":false,"description":"Minimum Verified Attention Seconds (7-day rolling average). VAS measures\nhow many seconds of verified human attention a screen receives per ad play.\nHigher VAS means more engaged audiences. Typical range: 2.0 to 15.0.\n","schema":{"type":"number","minimum":0},"example":5},{"name":"min_attention","in":"query","required":false,"description":"Minimum attention score on a 0-100 scale. Attention score measures the\npercentage of detected faces that are looking at the screen. A score of\n60 means 60% of people facing the screen are actively looking at it.\n","schema":{"type":"number","minimum":0,"maximum":100},"example":60},{"name":"emotion","in":"query","required":false,"description":"Filter by dominant audience emotion detected by on-device face analysis.\nReturns only screens where the current dominant emotion matches.\n","schema":{"type":"string","enum":["happy","sad","angry","surprised","neutral","fear","disgust"]},"example":"happy"},{"name":"cursor","in":"query","required":false,"description":"Opaque pagination cursor. Pass the `cursor.next` value from a\nprevious response to fetch the subsequent page. Cursors encode\nthe (updated_at, id) tuple of the last screen returned, so\npagination remains stable across concurrent inventory updates.\nOmit on the first request to start from the beginning.\n","schema":{"type":"string"},"example":"eyJsYXN0X3VwZGF0ZWRfYXQiOiIyMDI2LTA1LTAxVDAwOjAwOjAwLjAwMFoiLCJsYXN0X2lkIjoxMjN9"},{"name":"since","in":"query","required":false,"description":"Delta-sync watermark. ISO8601 timestamp; only screens whose\n`updated_at >= since` are returned. Use this for hourly cron\npolling — pass the timestamp of the last successful sync to\nfetch only screens that changed since then.\n","schema":{"type":"string","format":"date-time"},"example":"2026-05-11T00:00:00Z"}],"responses":{"200":{"description":"Available inventory. Response shape depends on the `format` query parameter:\n\n- **Default (`format=screens`):** Returns `{ total, screens, fein_coverage }`\n  with screen summaries and FEIN sensor coverage statistics.\n- **OpenRTB (`format=openrtb`):** Returns `{ available_slots, bid_requests }`\n  with full OpenRTB 2.6 BidRequest objects extended with `ext.trb.fein` for\n  audience signals.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":200}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":199}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"oneOf":[{"title":"Default format (format=screens)","type":"object","description":"Returned when `format=screens` (default). Provides screen summaries with FEIN sensor coverage statistics. Each screen object additionally surfaces `image_url`, `operating_hours`, `slot_duration_seconds`, and `updated_at` at the top level for AMP-style product catalog ingestion.\n","properties":{"total":{"type":"integer","description":"Number of screens on this page","example":50},"screens":{"type":"array","description":"Array of screen summaries. Each item includes the\nstandard ScreenSummary fields plus the AMP DSP-feed\nfields surfaced for inventory cron ingestion:\n- `image_url` — Optional CDN-hosted preview/thumbnail URL.\n- `operating_hours` — Optional JSON describing\n  screen operating windows (shape is partner-defined).\n- `slot_duration_seconds` — Default slot length in\n  seconds. Defaults to 15s when not set.\n- `updated_at` — ISO8601 timestamp of the last\n  screen mutation; use as the `since` watermark on\n  the next sync.\n","items":{"$ref":"#/components/schemas/ScreenSummary"}},"cursor":{"type":"object","description":"Keyset pagination cursor. When `has_more` is true,\npass `next` back as `?cursor=<value>` to fetch the\nfollowing page. When the page is terminal (fewer\nrows than the requested `limit`), `next` is `null`.\n","properties":{"next":{"type":"string","nullable":true,"description":"Opaque base64 cursor for the next page, or\n`null` if this is the last page. Encodes the\n(updated_at, id) tuple of the last row.\n","example":"eyJsYXN0X3VwZGF0ZWRfYXQiOiIyMDI2LTA1LTAxVDAwOjAwOjAwLjAwMFoiLCJsYXN0X2lkIjoxMjN9"},"has_more":{"type":"boolean","description":"`true` if at least `limit` rows were returned\nfrom PostgreSQL (more pages may exist);\n`false` when the result set is exhausted.\n","example":false}}},"fein_coverage":{"type":"object","description":"FEIN sensor coverage across matching screens","properties":{"screens_with_fein":{"type":"integer","description":"Number of screens with FEIN sensors","example":89},"screens_without_fein":{"type":"integer","description":"Number of screens without FEIN sensors","example":53}}}}},{"title":"OpenRTB format (format=openrtb)","type":"object","description":"Returned when `format=openrtb`. Provides full OpenRTB 2.6 bid request objects suitable for programmatic bidding.\n","properties":{"available_slots":{"type":"integer","description":"Total number of ad slots returned in this response","example":47},"bid_requests":{"type":"array","description":"Standard OpenRTB 2.6 bid request objects. Each represents one screen\nwith one or more impression opportunities. FEIN signals are in\n`ext.trb.fein` on each bid request.\n","items":{"type":"object","description":"OpenRTB 2.6 BidRequest with Trillboards FEIN extension","properties":{"id":{"type":"string","description":"Unique bid request ID","example":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04"},"imp":{"type":"array","description":"Array of impression opportunities on this screen","items":{"type":"object","properties":{"id":{"type":"string","description":"Impression ID — use this as `impid` in your bid","example":"imp_001"},"banner":{"type":"object","description":"Banner impression object (for static/image ads)","properties":{"w":{"type":"integer","description":"Width in pixels","example":1920},"h":{"type":"integer","description":"Height in pixels","example":1080}}},"video":{"type":"object","description":"Video impression object (for video/VAST ads)","properties":{"mimes":{"type":"array","items":{"type":"string"},"description":"Supported MIME types","example":["video/mp4","video/webm"]},"minduration":{"type":"integer","description":"Minimum video duration in seconds","example":5},"maxduration":{"type":"integer","description":"Maximum video duration in seconds","example":30},"w":{"type":"integer","example":1920},"h":{"type":"integer","example":1080},"protocols":{"type":"array","items":{"type":"integer"},"description":"Supported video protocols.\n2=VAST 2.0, 3=VAST 3.0, 5=VAST 2.0 Wrapper,\n6=VAST 3.0 Wrapper, 7=VAST 4.0, 8=VAST 4.1\n","example":[2,3,7]}}},"bidfloor":{"type":"number","description":"Minimum CPM bid price in USD","example":2.5},"bidfloorcur":{"type":"string","description":"Currency of the bid floor","example":"USD"}}}},"site":{"type":"object","description":"Publisher site/app information","properties":{"id":{"type":"string","description":"Trillboards publisher ID","example":"pub_trillboards"},"name":{"type":"string","example":"Trillboards DOOH Network"},"domain":{"type":"string","example":"trillboards.com"},"publisher":{"type":"object","properties":{"id":{"type":"string","example":"pub_trillboards"},"name":{"type":"string","example":"Trillboards Inc."}}}}},"device":{"type":"object","description":"Device (screen) information","properties":{"ua":{"type":"string","description":"User agent string of the screen device","example":"Mozilla/5.0 (Linux; Android 12; SHIELD Android TV)"},"geo":{"type":"object","description":"Geographic location of the screen","properties":{"lat":{"type":"number","description":"Latitude","example":40.7589},"lon":{"type":"number","description":"Longitude","example":-73.9851},"country":{"type":"string","description":"ISO 3166-1 alpha-3 country code","example":"USA"},"region":{"type":"string","description":"State or region code","example":"NY"},"city":{"type":"string","example":"New York"},"zip":{"type":"string","example":"10036"},"type":{"type":"integer","description":"Location type (1=GPS, 2=IP, 3=user-provided)","example":1}}},"devicetype":{"type":"integer","description":"OpenRTB device type.\n3=Connected TV, 6=Connected Device, 7=Set Top Box\n","example":3},"ifa":{"type":"string","description":"Device advertising ID (if available)","example":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}}},"ext":{"type":"object","description":"Trillboards-specific extensions","properties":{"trb":{"type":"object","description":"Trillboards extension namespace","properties":{"screen_id":{"type":"string","description":"Trillboards screen identifier","example":"scr_7f8a9b0c1d2e3f4a"},"screen_name":{"type":"string","description":"Human-readable screen name","example":"Times Square Deli - Counter Display"},"venue_type":{"type":"string","description":"Venue classification","example":"restaurant"},"fein":{"type":"object","description":"FEIN (Face/Edge Intelligence Network) real-time audience signals.\nThese signals come from on-device edge AI and are updated every\n10-30 seconds for screens with FEIN sensors.\n","properties":{"live_face_count":{"type":"integer","description":"Number of people currently facing the screen","example":12},"attention_score":{"type":"number","description":"Percentage of detected faces actively looking at the screen,\nnormalized to 0.0-1.0 range. A score of 0.73 means 73% of\npeople facing the screen are paying attention.\n","minimum":0,"maximum":1,"example":0.73},"dominant_emotion":{"type":"string","description":"Most common emotion detected among visible faces","nullable":true,"enum":["happy","sad","angry","surprised","neutral","fear","disgust"],"example":"happy"},"vas_7d":{"type":"number","description":"Verified Attention Seconds — 7-day weighted rolling average.\nMeasures how many seconds of verified human attention the screen\nreceives per ad play. Higher is better. Industry average: 3-5s.\n","nullable":true,"example":8.4},"crowd_density":{"type":"integer","description":"Estimated total venue occupancy (not just faces at screen)","nullable":true,"example":45},"purchase_intent":{"type":"string","description":"Speech-derived purchase intent level from ambient audio analysis.\nIndicates whether nearby conversations suggest buying intent.\n","nullable":true,"example":"high"},"ad_receptivity":{"type":"number","description":"Predicted ad receptivity score combining attention, emotion,\nand dwell time into a single 0.0-1.0 score. Higher means\nthe audience is more likely to engage with advertising.\n","nullable":true,"minimum":0,"maximum":1,"example":0.82},"income_level":{"type":"string","description":"Inferred income bracket based on venue type, location,\nand audience demographics. Updated hourly.\n","nullable":true,"enum":["low","medium","high","premium"],"example":"high"},"dwell_time_ms":{"type":"integer","description":"Average audience dwell time in milliseconds. Measures how\nlong people stay within view of the screen.\n","example":47000},"data_quality":{"type":"string","description":"Indicates the freshness and source of FEIN data.\n- live: real-time data from on-device sensors (updated every 10-30s)\n- historical: only VAS historical averages available (hourly)\n- none: no audience data for this screen\n","enum":["live","historical","none"],"example":"live"},"last_updated":{"type":"string","format":"date-time","description":"Timestamp of last FEIN signal update","nullable":true,"example":"2026-04-03T14:22:10.000Z"}}}}}}}}}},"cur":{"type":"string","description":"Currency for bid floor prices (always USD)","example":"USD"},"tmax":{"type":"integer","description":"Maximum time in milliseconds to submit a bid response","example":500},"filters_applied":{"type":"object","description":"Echo of the filters that were applied to this request","properties":{"venue_type":{"type":"string","nullable":true,"description":"Venue type filter that was applied","example":"retail"},"country":{"type":"string","nullable":true,"description":"Country filter that was applied","example":"US"},"state":{"type":"string","nullable":true,"example":"NY"},"city":{"type":"string","nullable":true,"example":"New York"},"min_vas":{"type":"number","nullable":true,"example":5},"min_attention":{"type":"number","nullable":true,"example":60},"emotion":{"type":"string","nullable":true,"example":"happy"}}},"signal_coverage":{"type":"object","description":"Breakdown of FEIN signal availability across matching screens.\nUseful for understanding how much of your target inventory has\naudience data.\n","properties":{"total_screens":{"type":"integer","description":"Total screens matching geographic and venue filters","example":142},"fein_enriched":{"type":"integer","description":"Screens with live or historical FEIN audience data","example":89},"after_filters":{"type":"integer","description":"Screens remaining after applying FEIN signal filters","example":47}}}}}]},"example":{"available_slots":47,"bid_requests":[{"id":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04","imp":[{"id":"imp_001","video":{"mimes":["video/mp4","video/webm"],"minduration":5,"maxduration":30,"w":1920,"h":1080,"protocols":[2,3,7]},"bidfloor":2.5,"bidfloorcur":"USD"}],"site":{"id":"pub_trillboards","name":"Trillboards DOOH Network","domain":"trillboards.com","publisher":{"id":"pub_trillboards","name":"Trillboards Inc."}},"device":{"ua":"Mozilla/5.0 (Linux; Android 12; SHIELD Android TV)","geo":{"lat":40.7589,"lon":-73.9851,"country":"USA","region":"NY","city":"New York","zip":"10036","type":1},"devicetype":3},"ext":{"trb":{"screen_id":"scr_7f8a9b0c1d2e3f4a","screen_name":"Times Square Deli - Counter Display","venue_type":"restaurant","fein":{"live_face_count":12,"attention_score":0.73,"dominant_emotion":"happy","vas_7d":8.4,"crowd_density":45,"purchase_intent":"high","ad_receptivity":0.82,"income_level":"high","dwell_time_ms":47000,"data_quality":"live","last_updated":"2026-04-03T14:22:10.000Z"}}}}],"cur":"USD","tmax":500,"filters_applied":{"venue_type":"retail","country":"US","state":null,"city":null,"min_vas":5,"min_attention":null,"emotion":null},"signal_coverage":{"total_screens":142,"fein_enriched":89,"after_filters":47}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/forecast":{"post":{"operationId":"forecast","summary":"Estimate campaign delivery and spend","description":"Given targeting criteria and a budget, estimates how many impressions you can\nexpect, your likely win rate, and average CPM. Forecasts are computed from\n7 days of historical ClickHouse rollup data from matching screens.\n\n**Use this before creating a campaign** to understand expected delivery and\noptimize your budget allocation.\n\nThe `confidence` field indicates forecast reliability:\n- **high** — 50%+ of matching screens have 7+ days of historical data\n- **medium** — Some screens have data but coverage is incomplete\n- **low** — Insufficient historical data; estimates are rough projections\n","tags":["Inventory"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["budget_usd"],"properties":{"targeting":{"type":"object","description":"Targeting criteria to scope the forecast. If omitted, forecast\ncovers all available inventory.\n","properties":{"venue_type":{"type":"string","description":"Venue type filter","example":"retail"},"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code","example":"US"},"state":{"type":"string","description":"State or province code","example":"CA"},"city":{"type":"string","description":"City name","example":"Los Angeles"}}},"budget_usd":{"type":"number","description":"Total campaign budget in USD. Must be greater than 0. The forecast\nwill estimate how many impressions this budget can buy at current\nmarket clearing prices.\n","minimum":0.01,"example":5000},"start_date":{"type":"string","format":"date","description":"Campaign start date in YYYY-MM-DD format. If omitted, defaults\nto tomorrow.\n","example":"2026-04-10"},"end_date":{"type":"string","format":"date","description":"Campaign end date in YYYY-MM-DD format. If omitted, defaults\nto 7 days after start_date.\n","example":"2026-04-17"},"pricing_model":{"type":"string","description":"Pricing model to use for the forecast.\n- **cpm** — Standard cost per mille (thousand impressions)\n- **vas_cpm** — Verified Attention Second CPM (premium pricing based on attention)\n","enum":["cpm","vas_cpm"],"default":"cpm","example":"cpm"}}},"example":{"targeting":{"country":"US","venue_type":"retail"},"budget_usd":5000,"start_date":"2026-04-10","end_date":"2026-04-17"}}}},"responses":{"200":{"description":"Delivery forecast computed successfully. When no screens match the\ntargeting criteria, the response contains only a `message` field\n(e.g., `{ \"message\": \"No screens match the specified targeting criteria\" }`)\ninstead of the full forecast object.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","nullable":true,"description":"Present only when zero screens match the targeting criteria. When this field is present, all other fields are omitted.\n","example":"No screens match the specified targeting criteria"},"estimated_impressions":{"type":"integer","description":"Estimated total impressions the budget can buy over the campaign period\nat current market clearing prices.\n","example":714285},"estimated_spend_usd":{"type":"number","description":"Estimated total spend in USD. May be less than budget_usd if inventory\nis limited for the targeting criteria.\n","example":4999.99},"estimated_win_rate":{"type":"number","description":"Estimated auction win rate as a decimal (0.0 to 1.0). Based on\nhistorical win rates for similar targeting and CPM ranges.\n","minimum":0,"maximum":1,"example":0.68},"matching_screen_count":{"type":"integer","description":"Number of active screens matching the targeting criteria","example":342},"avg_cpm":{"type":"number","description":"Average CPM in USD across matching screens based on 7-day\nhistorical clearing prices.\n","example":7},"campaign_days":{"type":"integer","description":"Number of days in the campaign period","example":7},"daily_impressions":{"type":"integer","description":"Estimated impressions per day (total divided by campaign days)","example":102040},"pricing_model":{"type":"string","description":"The pricing model used for this forecast","enum":["cpm","vas_cpm"],"example":"cpm"},"confidence":{"type":"string","description":"Forecast confidence level based on historical data availability.\n- high: 50%+ screens have 7+ days of data\n- medium: some screens have data but coverage is incomplete\n- low: insufficient data, rough projections only\n","enum":["high","medium","low"],"example":"high"}}},"example":{"estimated_impressions":714285,"estimated_spend_usd":4999.99,"estimated_win_rate":0.68,"matching_screen_count":342,"avg_cpm":7,"campaign_days":7,"daily_impressions":102040,"pricing_model":"cpm","confidence":"high"}}}},"400":{"description":"Invalid request parameters. The `budget_usd` field is required and must be\ngreater than 0. Date formats must be valid YYYY-MM-DD.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"budget_usd is required and must be greater than 0"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/discover":{"post":{"operationId":"discover","summary":"Semantic inventory search","x-beta":true,"description":"Find screens using natural language queries. Uses **pgvector semantic embeddings**\nto match your query against scene descriptions generated by on-device vision\nlanguage models (VLMs).\n\n**Example queries:**\n- \"busy coffee shops with young professionals in Manhattan\"\n- \"gym lobbies with TVs near college campuses\"\n- \"high-end restaurants in Beverly Hills with bar seating\"\n- \"transit hubs with high foot traffic during rush hour\"\n\nResults are ranked by cosine similarity to your query embedding. Each result\nincludes screen details and FEIN signals (when available).\n\nOptionally provide a `budget` to receive delivery estimates alongside results.\n\n**Note:** When the semantic search backend (pgvector) is unavailable, the\nresponse is simplified — results are returned without similarity scores and\nthe `fein` object is omitted from each result. The endpoint still returns\nmatching screens based on keyword/filter fallback.\n","tags":["Inventory"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query"],"properties":{"query":{"type":"string","description":"Natural language description of your desired inventory. Be as\nspecific as possible — include venue type, audience demographics,\ngeography, and any other criteria. The query is embedded using\nthe same model that encodes scene descriptions from on-device VLMs.\n","minLength":3,"maxLength":500,"example":"busy coffee shops with young professionals in NYC"},"budget":{"type":"number","description":"Optional budget in USD. If provided, each result includes a delivery\nestimate for that screen based on historical data.\n","minimum":0.01,"example":2500},"limit":{"type":"integer","description":"Maximum number of results to return. Default is 20, maximum is 50.\nResults are ordered by semantic similarity score descending.\n","default":20,"minimum":1,"maximum":50,"example":20}}},"example":{"query":"busy coffee shops with young professionals in NYC","budget":2500,"limit":20}}}},"responses":{"200":{"description":"Matching screens ranked by semantic similarity","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"query":{"type":"string","description":"Echo of the original query","example":"busy coffee shops with young professionals in NYC"},"results":{"type":"array","description":"Screens matching the query, ordered by cosine similarity score.\nEach result includes screen details and FEIN signals when available.\n","items":{"type":"object","properties":{"screen_id":{"type":"string","description":"Trillboards screen identifier","example":"scr_4a8b2c1d3e5f6a7b"},"name":{"type":"string","description":"Human-readable screen name","example":"Blue Bottle Coffee - SoHo Counter"},"venue_type":{"type":"string","nullable":true,"description":"Venue classification","example":"coffee_shop"},"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code","example":"US"},"city":{"type":"string","nullable":true,"description":"City where the screen is located","example":"New York"},"fein":{"type":"object","nullable":true,"description":"FEIN signals (null if screen has no FEIN sensors)","properties":{"live_face_count":{"type":"integer","description":"People currently facing the screen","example":8},"attention_score":{"type":"number","minimum":0,"maximum":1,"description":"Average gaze attention (0-1)","example":0.65},"dominant_emotion":{"type":"string","nullable":true,"example":"neutral"},"vas_7d":{"type":"number","nullable":true,"description":"Verified Attention Seconds (7-day average)","example":6.2},"crowd_density":{"type":"integer","nullable":true,"example":28},"purchase_intent":{"type":"string","nullable":true,"example":"medium"},"ad_receptivity":{"type":"number","nullable":true,"minimum":0,"maximum":1,"example":0.71},"income_level":{"type":"string","nullable":true,"enum":["low","medium","high","premium"],"example":"high"},"dwell_time_ms":{"type":"integer","description":"Average dwell time in milliseconds","example":38000},"data_quality":{"type":"string","enum":["live","historical","none"],"example":"live"},"last_updated":{"type":"string","format":"date-time","nullable":true,"example":"2026-04-03T14:18:45.000Z"}}}}}},"total":{"type":"integer","description":"Total number of matching screens (may exceed limit)","example":34}}},"example":{"query":"busy coffee shops with young professionals in NYC","results":[{"screen_id":"scr_4a8b2c1d3e5f6a7b","name":"Blue Bottle Coffee - SoHo Counter","venue_type":"coffee_shop","country":"US","city":"New York","fein":{"live_face_count":8,"attention_score":0.65,"dominant_emotion":"neutral","vas_7d":6.2,"crowd_density":28,"purchase_intent":"medium","ad_receptivity":0.71,"income_level":"high","dwell_time_ms":38000,"data_quality":"live","last_updated":"2026-04-03T14:18:45.000Z"}},{"screen_id":"scr_9c7d6e5f4a3b2c1d","name":"Stumptown Coffee - Chelsea","venue_type":"coffee_shop","country":"US","city":"New York","fein":{"live_face_count":5,"attention_score":0.58,"dominant_emotion":"happy","vas_7d":5.1,"crowd_density":18,"purchase_intent":"low","ad_receptivity":0.63,"income_level":"premium","dwell_time_ms":52000,"data_quality":"live","last_updated":"2026-04-03T14:19:02.000Z"}}],"total":34}}}},"400":{"description":"Invalid request — the `query` field is required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"query is required and must be a non-empty string"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/bid":{"post":{"operationId":"acceptBidResponse","summary":"Submit OpenRTB 2.6 bid response","description":"Submit a standard OpenRTB 2.6 bid response. Bids are validated against\ncreative blocklists and floor prices, then entered into the live waterfall\nauction (or scored-only in sandbox mode).\n\n**Each bid requires:**\n- `impid` — Impression ID from the `/adslots` bid request\n- `price` — CPM bid price in USD (must be > 0)\n- `adm` OR `nurl` — Either inline ad markup (VAST XML, HTML) or a win notice URL\n\nBids missing both `adm` and `nurl` are rejected with reason `missing_adm_and_nurl`.\nBids below the floor price are rejected with reason `below_floor` (the floor price\nand your bid price are included in the rejection for transparency).\n\n**Response includes per-bid granularity:** each bid is individually accepted or\nrejected, so a single bid response can have both accepted and rejected bids.\n\n**`nurl` macro contract (PR 4):** when your cached bid wins delivery, Trillboards\nfires a GET to your `nurl` URL with these macros expanded before the request\nleaves our edge:\n\n| Macro | Replaced with |\n| --- | --- |\n| `${AUCTION_ID}` | request id of the served opportunity |\n| `${AUCTION_BID_ID}` | the `bid.id` from your seatbid |\n| `${AUCTION_IMP_ID}` | the `bid.impid` |\n| `${AUCTION_SEAT_ID}` | your DSP seat |\n| `${AUCTION_AD_ID}` | the `bid.adid` |\n| `${AUCTION_PRICE}` | the clearing CPM (USD) |\n| `${AUCTION_CURRENCY}` | always `USD` today |\n\nThe nurl call is fire-and-forget on the server side — a 4xx from your\nendpoint never blocks the served VAST, but it WILL surface in our\npartner-recon reports as a delivery anomaly.\n\n**Rate limit:** 1,000 requests/minute (production), 100 requests/minute (sandbox).\n","tags":["Bidding"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","seatbid"],"description":"Standard OpenRTB 2.6 bid response object. Must contain at least one\nseatbid with at least one bid. Each bid must reference a valid impression\nID from a recent `/adslots` response.\n","properties":{"id":{"type":"string","description":"Bid response ID. Must match the bid request ID from `/adslots`.\nUsed for correlation in win/loss notifications.\n","example":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04"},"seatbid":{"type":"array","description":"Array of seat bid objects, one per buyer seat","minItems":1,"items":{"type":"object","properties":{"bid":{"type":"array","description":"Array of individual bid objects","minItems":1,"items":{"type":"object","required":["impid","price"],"properties":{"id":{"type":"string","description":"Unique bid ID generated by your DSP. Used in win/loss\nnotifications and reporting. If omitted, Trillboards\ngenerates one.\n","example":"bid_acme_20260403_001"},"impid":{"type":"string","description":"Impression ID from the bid request. Must match an `imp.id`\nfrom the `/adslots` response.\n","example":"imp_001"},"price":{"type":"number","description":"Bid price in CPM (USD). Must be greater than 0. Bids below\nthe floor price are rejected with reason `below_floor`.\n","minimum":0.01,"example":8.5},"adm":{"type":"string","description":"Inline ad markup. For video ads, this should be VAST XML.\nFor display ads, HTML markup. Either `adm` or `nurl` must\nbe provided.\n","example":"<VAST version=\"4.0\"><Ad><InLine><AdSystem>Acme DSP</AdSystem><AdTitle>Summer Sale</AdTitle><Creatives><Creative><Linear><Duration>00:00:15</Duration><MediaFiles><MediaFile delivery=\"progressive\" type=\"video/mp4\" width=\"1920\" height=\"1080\">https://cdn.acme-ads.com/creatives/summer-sale-15s.mp4</MediaFile></MediaFiles></Linear></Creative></Creatives></InLine></Ad></VAST>"},"nurl":{"type":"string","format":"uri","description":"Win notice URL. Called when this bid wins the auction.\nThe response body should contain the ad markup. Either\n`adm` or `nurl` must be provided.\n","example":"https://acme-ads.com/vast/win?bid=${AUCTION_ID}&price=${AUCTION_PRICE}"},"adomain":{"type":"array","description":"Advertiser domains for blocklist checking. Include all\ndomains that appear in the creative landing page.\n","items":{"type":"string"},"example":["acme-retail.com","acme-ads.com"]},"crid":{"type":"string","description":"Creative ID from your DSP. Used for creative-level\nreporting and blocklist management.\n","example":"crid_summer_sale_15s"},"dealid":{"type":"string","description":"Deal ID if this bid is for a PMP or preferred deal.\nMust reference an active deal for your seat.\n","example":"deal_retail_q2_2026"},"w":{"type":"integer","description":"Creative width in pixels","example":1920},"h":{"type":"integer","description":"Creative height in pixels","example":1080}}}},"seat":{"type":"string","description":"Buyer seat ID (must match your registered seat_id)","example":"acme_ads"}}}},"cur":{"type":"string","description":"Bid currency (currently only USD is supported)","default":"USD","example":"USD"}}},"example":{"id":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04","seatbid":[{"bid":[{"id":"bid_acme_20260403_001","impid":"imp_001","price":8.5,"adm":"<VAST version=\"4.0\"><Ad><InLine><AdSystem>Acme DSP</AdSystem><AdTitle>Summer Sale</AdTitle><Creatives><Creative><Linear><Duration>00:00:15</Duration><MediaFiles><MediaFile delivery=\"progressive\" type=\"video/mp4\" width=\"1920\" height=\"1080\">https://cdn.acme-ads.com/creatives/summer-sale-15s.mp4</MediaFile></MediaFiles></Linear></Creative></Creatives></InLine></Ad></VAST>","adomain":["acme-retail.com"],"crid":"crid_summer_sale_15s"},{"id":"bid_acme_20260403_002","impid":"imp_002","price":6.25,"nurl":"https://acme-ads.com/vast/win?bid=${AUCTION_ID}&price=${AUCTION_PRICE}","adomain":["acme-retail.com"],"crid":"crid_spring_collection_30s"}],"seat":"acme_ads"}],"cur":"USD"}}}},"responses":{"200":{"description":"Bid processing result. Each bid is individually accepted or rejected.\nA single response can contain both accepted and rejected bids.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":1000}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":997}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Bid request ID echoed back","example":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04"},"nbr":{"type":"integer","description":"OpenRTB no-bid reason code (0=accepted, see OpenRTB 2.6 Table 5.24). Common values: 0=accepted, 1=unknown error, 2=known web spider, 3=suspicious activity, 4=URL blocklist, 5=below floor, 6=unresolvable creative, 7=duplicate, 8=no matching impressions.\n","example":0},"rejections":{"type":"array","nullable":true,"description":"Per-impression rejection details. Only present when one or more\nbids are rejected. Each entry identifies the impression and reason.\n","items":{"type":"object","properties":{"impid":{"type":"string","description":"Impression ID that was rejected","example":"imp_001"},"reason":{"type":"string","description":"Human-readable rejection reason","example":"below_floor"},"nbr":{"type":"integer","description":"OpenRTB no-bid reason code for this impression","example":5}}}}}},"example":{"id":"br_8f3a2c1e-9d47-4b6a-a1c3-2f8e5d7b9a04","nbr":0,"rejections":null}}}},"400":{"description":"Invalid bid response. The request body must be a valid OpenRTB 2.6 bid response\nwith at least one seatbid containing at least one bid.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Invalid bid response: seatbid array is required and must not be empty"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"415":{"description":"Content-Type must be `application/json`. Ensure your request includes\nthe header `Content-Type: application/json`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Content-Type must be application/json"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/creatives":{"post":{"operationId":"createCreative","summary":"Upload a creative for AI moderation","description":"Upload a creative asset for automatic Gemini AI moderation. Two upload modes\nare supported:\n\n**URL mode** — Provide a `creative_url` pointing to your hosted asset. The URL\nmust be publicly accessible. Trillboards downloads the asset and begins AI\nmoderation immediately.\n\n```json\n{\n  \"name\": \"Summer Sale 15s\",\n  \"content_type\": \"video/mp4\",\n  \"creative_url\": \"https://cdn.example.com/ads/summer-sale.mp4\"\n}\n```\n\n**Upload mode** — Get a presigned S3 URL for direct upload. Provide a `filename`\ninstead of `creative_url`. The response includes `upload.upload_url` — PUT your\nfile there with the specified Content-Type header, then call\n`POST /creatives/{id}/confirm` to trigger moderation.\n\n```json\n{\n  \"name\": \"Summer Sale 15s\",\n  \"content_type\": \"video/mp4\",\n  \"filename\": \"summer-sale.mp4\"\n}\n```\n\n**AI moderation** typically completes within 30 seconds and produces:\n- Content classification (8 safety flags)\n- MPAA-style age rating (G through NC-17)\n- Brand recognition\n- IAB Content Taxonomy 1.0 category codes\n- Content analysis (subjects, setting, mood, quality, keywords)\n\nPoll `GET /creatives/{id}` to check moderation status. Only creatives with\n`status: approved` can be used in campaigns.\n\n**Rate limit:** 100 requests/minute.\n","tags":["Creatives"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","content_type"],"description":"Creative upload request. Exactly one of `creative_url` (URL mode) or\n`filename` (upload mode) must be provided. If neither is present, the\nrequest is rejected with 400.\n","properties":{"name":{"type":"string","description":"Human-readable creative name for your reference. Appears in\nreporting dashboards and moderation results.\n","minLength":1,"maxLength":200,"example":"Summer Sale 15s Video"},"content_type":{"type":"string","description":"MIME type of the creative asset. Must match the actual file format.\nMismatched content types will cause moderation failures.\n","enum":["video/mp4","video/quicktime","video/webm","image/jpeg","image/png","image/webp","image/gif"],"example":"video/mp4"},"creative_url":{"type":"string","format":"uri","description":"**URL mode:** Publicly accessible URL of the creative asset.\nTrillboards downloads the asset and begins moderation immediately.\nMust be HTTPS. Maximum file size: 100 MB.\n","example":"https://cdn.acme-ads.com/creatives/summer-sale-15s.mp4"},"filename":{"type":"string","description":"**Upload mode:** Filename for presigned S3 upload. The response will\ninclude a `upload.upload_url` for direct file upload. After uploading,\ncall `POST /creatives/{id}/confirm` to trigger moderation.\n","example":"summer-sale-15s.mp4"},"description":{"type":"string","description":"Optional description of the creative content","maxLength":1000,"example":"15-second video promoting summer retail sale with beach imagery"},"campaign_link":{"type":"string","format":"uri","description":"Click-through URL for interactive screens. Opened when a viewer\ntaps or interacts with the ad on touch-enabled displays.\n","example":"https://acme-retail.com/summer-sale"}}},"example":{"name":"Summer Sale 15s Video","content_type":"video/mp4","creative_url":"https://cdn.acme-ads.com/creatives/summer-sale-15s.mp4","description":"15-second video promoting summer retail sale with beach imagery","campaign_link":"https://acme-retail.com/summer-sale"}}}},"responses":{"201":{"description":"Creative created and moderation enqueued. For URL mode, moderation begins\nimmediately. For upload mode, moderation begins after you upload the file\nand call `POST /creatives/{id}/confirm`.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the creative was created successfully","example":true},"data":{"type":"object","description":"Created creative details","properties":{"creative_id":{"type":"string","description":"Unique creative identifier. Use this in campaigns and to poll moderation status.","example":"6620abc123def456789abcde"},"name":{"type":"string","description":"Creative name as provided","example":"Summer Sale 15s Video"},"content_type":{"type":"string","description":"MIME type of the creative","example":"video/mp4"},"asset_url":{"type":"string","description":"CDN URL where the creative is (or will be) hosted. For URL mode,\nthis is the Trillboards CDN copy. For upload mode, this is\npopulated after upload and confirmation.\n","example":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4"},"status":{"type":"string","description":"Current creative status:\n- `pending_moderation` — Waiting for AI moderation to complete\n- `approved` — Passed moderation, ready for campaigns\n- `rejected` — Failed moderation (check flags for details)\n- `active` — Currently assigned to one or more screens\n- `deactivated` — Manually deactivated by DSP\n","enum":["pending_moderation","approved","rejected","active","deactivated"],"example":"pending_moderation"},"moderation":{"type":"object","description":"Current moderation pipeline status","properties":{"status":{"type":"string","description":"Moderation pipeline status:\n- `queued` — Waiting in the moderation queue\n- `processing` — AI moderation is running\n- `completed` — Moderation finished (check creative status for result)\n","enum":["queued","processing","completed"],"example":"queued"},"description":{"type":"string","description":"Human-readable moderation status message","example":"Creative submitted for AI moderation. Poll GET /creatives/6620abc123def456789abcde for results (~30 seconds)."}}},"upload":{"type":"object","nullable":true,"description":"Upload instructions. **Only present in upload mode** (when `filename`\nwas provided instead of `creative_url`). PUT your file to `upload_url`\nwith the specified Content-Type header, then call\n`POST /creatives/{id}/confirm`.\n","properties":{"upload_url":{"type":"string","description":"Presigned S3 URL for file upload. Send a PUT request with your\nfile as the body and the specified Content-Type header.\n","example":"https://trillboards-dsp-uploads.s3.amazonaws.com/acme_ads/summer-sale-15s.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=..."},"cdn_url":{"type":"string","description":"CDN URL where the file will be served after upload","example":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4"},"expires_in":{"type":"integer","description":"Presigned URL expiration time in seconds","example":3600},"method":{"type":"string","description":"HTTP method to use for upload","example":"PUT"},"headers":{"type":"object","description":"Required headers for the upload request","properties":{"Content-Type":{"type":"string","description":"Content-Type header value for the upload","example":"video/mp4"}}}}}}}}},"example":{"success":true,"data":{"creative_id":"6620abc123def456789abcde","name":"Summer Sale 15s Video","content_type":"video/mp4","asset_url":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4","status":"pending_moderation","moderation":{"status":"queued","description":"Creative submitted for AI moderation. Poll GET /creatives/6620abc123def456789abcde for results (~30 seconds)."},"upload":null}}}}},"400":{"description":"Invalid request. Common causes:\n- Missing required field `name` or `content_type`\n- Neither `creative_url` nor `filename` provided\n- Invalid `content_type` value\n- `creative_url` is not a valid HTTPS URL\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Either creative_url or filename must be provided"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"get":{"operationId":"listCreatives","summary":"List your creatives","description":"Returns all creatives for the authenticated DSP with moderation status, IAB\ncategories, and pagination. Use the `status` filter to find creatives in a\nspecific state (e.g., only approved creatives ready for campaigns).\n","tags":["Creatives"],"parameters":[{"name":"status","in":"query","required":false,"description":"Filter by creative status. If omitted, returns creatives in all statuses.\n","schema":{"type":"string","enum":["pending_moderation","approved","rejected","active","deactivated"]},"example":"approved"},{"name":"limit","in":"query","required":false,"description":"Maximum number of creatives to return per page. Values above 200 are capped to 200 silently (the server will not return an error).\n","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50},{"name":"offset","in":"query","required":false,"description":"Number of creatives to skip for pagination","schema":{"type":"integer","default":0,"minimum":0},"example":0}],"responses":{"200":{"description":"List of creatives with moderation status","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the request was successful","example":true},"data":{"type":"array","description":"Array of creative summary objects","items":{"type":"object","properties":{"creative_id":{"type":"string","description":"Unique creative identifier","example":"6620abc123def456789abcde"},"name":{"type":"string","description":"Creative name","example":"Summer Sale 15s Video"},"status":{"type":"string","description":"Current creative status","enum":["pending_moderation","approved","rejected","active","deactivated"],"example":"approved"},"content_type":{"type":"string","description":"MIME type","example":"video/mp4"},"asset_url":{"type":"string","description":"CDN URL of the creative asset","example":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4"},"moderation_status":{"type":"string","description":"AI moderation pipeline status","enum":["pending","approved","rejected"],"example":"approved"},"iab_categories":{"type":"array","description":"IAB Content Taxonomy 1.0 category codes assigned by AI moderation","items":{"type":"string"},"example":["IAB18","IAB22-1"]},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp","example":"2026-04-02T10:15:30.000Z"}}}},"pagination":{"type":"object","description":"Pagination metadata","properties":{"limit":{"type":"integer","description":"Number of results per page","example":50},"offset":{"type":"integer","description":"Number of results skipped","example":0},"count":{"type":"integer","description":"Total number of creatives matching the filter","example":12}}}}},"example":{"success":true,"data":[{"creative_id":"6620abc123def456789abcde","name":"Summer Sale 15s Video","status":"approved","content_type":"video/mp4","asset_url":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4","moderation_status":"approved","iab_categories":["IAB18","IAB22-1"],"created_at":"2026-04-02T10:15:30.000Z"},{"creative_id":"6620def456789abcde012345","name":"Spring Collection Banner","status":"active","content_type":"image/jpeg","asset_url":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/spring-collection.jpg","moderation_status":"approved","iab_categories":["IAB18-1","IAB22"],"created_at":"2026-03-28T16:42:00.000Z"},{"creative_id":"6620fed987654321cba09876","name":"Holiday Promo 30s","status":"pending_moderation","content_type":"video/mp4","asset_url":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/holiday-promo.mp4","moderation_status":"pending","iab_categories":[],"created_at":"2026-04-03T14:30:00.000Z"}],"pagination":{"limit":50,"offset":0,"count":12}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/creatives/{id}/confirm":{"post":{"operationId":"confirmCreativeUpload","summary":"Confirm presigned upload and trigger moderation","description":"After uploading a creative via the presigned URL (upload mode), call this endpoint\nto confirm the upload and trigger AI moderation.\n\n**This endpoint is only needed for upload mode** (when you provided `filename`\ninstead of `creative_url` during creation). URL mode creatives are moderated\nautomatically.\n\nNo request body is required. The endpoint verifies the file exists in S3 and\nenqueues the AI moderation pipeline.\n\n**Rate limit:** 100 requests/minute.\n","tags":["Creatives"],"parameters":[{"name":"id","in":"path","required":true,"description":"Creative ID returned from `POST /creatives`","schema":{"type":"string"},"example":"6620abc123def456789abcde"}],"responses":{"200":{"description":"Upload confirmed and moderation triggered","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"creative_id":{"type":"string","description":"Creative ID","example":"6620abc123def456789abcde"},"status":{"type":"string","description":"Updated creative status","example":"pending_moderation"},"moderation":{"type":"object","properties":{"status":{"type":"string","example":"queued"},"description":{"type":"string","example":"Upload confirmed. AI moderation queued. Poll GET /creatives/6620abc123def456789abcde for results (~30 seconds)."}}}}}}},"example":{"success":true,"data":{"creative_id":"6620abc123def456789abcde","status":"pending_moderation","moderation":{"status":"queued","description":"Upload confirmed. AI moderation queued. Poll GET /creatives/6620abc123def456789abcde for results (~30 seconds)."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Creative not found or does not belong to your DSP","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Creative not found"}}}},"422":{"description":"Upload not found. The file has not been uploaded to the presigned URL yet,\nor the presigned URL has expired.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"File not found at upload URL. Upload the file before confirming."}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/creatives/{id}":{"get":{"operationId":"getCreative","summary":"Get creative details with full moderation results","description":"Returns full creative details including AI moderation results. Use this endpoint\nto:\n\n- **Poll moderation status** after uploading a creative (check `moderation.status`)\n- **Review content classification** flags (alcohol, tobacco, gambling, etc.)\n- **Check age rating** (G, PG, PG-13, R, NC-17)\n- **View IAB categories** assigned by the AI pipeline\n- **See content analysis** (subjects, setting, mood, quality, keywords)\n- **Verify brand detection** results\n\n**Moderation timeline:**\n1. `status: pending_moderation` — Creative is in the queue (0-10 seconds)\n2. `status: pending_moderation` with `moderation.status: processing` — AI is analyzing (10-25 seconds)\n3. `status: approved` or `status: rejected` — Moderation complete (~30 seconds total)\n\n**Tip:** Poll every 5 seconds until `moderation.status` is `completed`.\n","tags":["Creatives"],"parameters":[{"name":"id","in":"path","required":true,"description":"Creative ID returned from `POST /creatives`","schema":{"type":"string"},"example":"6620abc123def456789abcde"}],"responses":{"200":{"description":"Creative details with full moderation results","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"creative_id":{"type":"string","description":"Unique creative identifier","example":"6620abc123def456789abcde"},"name":{"type":"string","description":"Creative name","example":"Summer Sale 15s Video"},"description":{"type":"string","nullable":true,"description":"Creative description (if provided during upload)","example":"15-second video promoting summer retail sale with beach imagery"},"status":{"type":"string","description":"Current creative status","enum":["pending_moderation","approved","rejected","active","deactivated"],"example":"approved"},"content_type":{"type":"string","description":"MIME type of the creative asset","example":"video/mp4"},"asset_url":{"type":"string","description":"CDN URL of the creative","example":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4"},"campaign_link":{"type":"string","nullable":true,"description":"Click-through URL for interactive screens","example":"https://acme-retail.com/summer-sale"},"moderation":{"type":"object","description":"AI moderation results from Gemini","properties":{"status":{"type":"string","description":"Moderation pipeline status","enum":["pending","approved","rejected"],"example":"approved"},"reviewed_at":{"type":"string","format":"date-time","nullable":true,"description":"When moderation completed","example":"2026-04-02T10:16:02.000Z"},"flags":{"type":"array","description":"Content flags raised during moderation. Empty array means\nno flags were raised. Possible values: alcohol, tobacco,\ngambling, political, violence, profanity, adult_content, firearms.\n","items":{"type":"string"},"example":[]},"content_classification":{"type":"object","description":"Detailed content classification with individual boolean flags\nfor each content category, age rating, brand detection, and\nconfidence score.\n","properties":{"contains_alcohol":{"type":"boolean","description":"Whether the creative contains alcohol-related content","example":false},"contains_tobacco":{"type":"boolean","description":"Whether the creative contains tobacco-related content","example":false},"contains_gambling":{"type":"boolean","description":"Whether the creative contains gambling-related content","example":false},"contains_political":{"type":"boolean","description":"Whether the creative contains political content","example":false},"contains_violence":{"type":"boolean","description":"Whether the creative depicts violence","example":false},"contains_profanity":{"type":"boolean","description":"Whether the creative contains profane language","example":false},"contains_adult_content":{"type":"boolean","description":"Whether the creative contains adult/sexual content","example":false},"contains_firearms":{"type":"boolean","description":"Whether the creative depicts firearms","example":false},"age_rating":{"type":"string","description":"MPAA-style age rating assigned by AI moderation.\nScreens can be configured to only show creatives up to\na certain rating based on venue type.\n","enum":["G","PG","PG-13","R","NC-17"],"example":"G"},"is_known_brand":{"type":"boolean","description":"Whether the AI detected a known brand in the creative","example":true},"confidence":{"type":"number","description":"Overall classification confidence score (0.0 to 1.0).\nScores below 0.7 may trigger manual review.\n","minimum":0,"maximum":1,"example":0.94}}},"analysis":{"type":"object","nullable":true,"description":"AI content analysis results","properties":{"subjects":{"type":"array","description":"Primary subjects identified in the creative","items":{"type":"string"},"example":["beach","summer clothing","retail store"]},"setting":{"type":"string","description":"Detected setting or environment","example":"beachfront retail store"},"mood":{"type":"string","description":"Overall mood or tone of the creative","example":"upbeat, energetic"},"quality":{"type":"string","description":"Production quality assessment.\nPossible values: professional, semi-professional, amateur.\n","example":"professional"},"keywords":{"type":"array","description":"Extracted keywords for categorization","items":{"type":"string"},"example":["summer","sale","fashion","beach","discount"]}}},"brand":{"type":"string","nullable":true,"description":"Detected brand name (null if no known brand identified)","example":"Acme Retail"},"category":{"type":"string","nullable":true,"description":"Primary content category","example":"Retail/Fashion"}}},"openrtb":{"type":"object","description":"OpenRTB-compatible fields for programmatic targeting","properties":{"cat":{"type":"array","description":"IAB Content Taxonomy 1.0 category codes. These codes are\nused for blocklist matching in programmatic auctions.\n","items":{"type":"string"},"example":["IAB18","IAB22-1"]}}},"created_at":{"type":"string","format":"date-time","description":"Creative creation timestamp","example":"2026-04-02T10:15:30.000Z"},"updated_at":{"type":"string","format":"date-time","description":"Last update timestamp (including moderation updates)","example":"2026-04-02T10:16:02.000Z"}}}}},"example":{"success":true,"data":{"creative_id":"6620abc123def456789abcde","name":"Summer Sale 15s Video","description":"15-second video promoting summer retail sale with beach imagery","status":"approved","content_type":"video/mp4","asset_url":"https://d1a2b3c4d5e6.cloudfront.net/dsp-creatives/acme_ads/summer-sale-15s.mp4","campaign_link":"https://acme-retail.com/summer-sale","moderation":{"status":"approved","reviewed_at":"2026-04-02T10:16:02.000Z","flags":[],"content_classification":{"contains_alcohol":false,"contains_tobacco":false,"contains_gambling":false,"contains_political":false,"contains_violence":false,"contains_profanity":false,"contains_adult_content":false,"contains_firearms":false,"age_rating":"G","is_known_brand":true,"confidence":0.94},"analysis":{"subjects":["beach","summer clothing","retail store"],"setting":"beachfront retail store","mood":"upbeat, energetic","quality":"professional","keywords":["summer","sale","fashion","beach","discount"]},"brand":"Acme Retail","category":"Retail/Fashion"},"openrtb":{"cat":["IAB18","IAB22-1"]},"created_at":"2026-04-02T10:15:30.000Z","updated_at":"2026-04-02T10:16:02.000Z"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Creative not found or does not belong to your DSP","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Creative not found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/campaigns":{"post":{"operationId":"createCampaign","summary":"Create a campaign with targeting","description":"Creates a campaign linking an approved creative to targeting criteria. The API\nreturns matching screens based on your targeting, so you can choose which\nspecific screens to assign.\n\n**The creative must have `status: approved`** (passed AI moderation). Campaigns\ncannot be created with pending or rejected creatives.\n\n**Workflow:**\n1. Create campaign with targeting criteria (this endpoint)\n2. Review the `screens` array in the response\n3. Call `POST /campaigns/assign` with your selected `screen_ids`\n\nThe response includes up to 20 matching screens. If `matching_screens` exceeds\n20, use the `/adslots` endpoint with the same targeting filters to browse\nthe full inventory.\n\n**Rate limit:** 100 requests/minute.\n","tags":["Campaigns"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["creative_id","name"],"properties":{"creative_id":{"type":"string","description":"ID of an approved creative (from `POST /creatives`). The creative\nmust have `status: approved` or `status: active`. Pending or\nrejected creatives will return 422.\n","example":"6620abc123def456789abcde"},"name":{"type":"string","description":"Campaign name for your reference. Appears in reporting dashboards.\n","minLength":1,"maxLength":200,"example":"Summer Retail Push - NYC"},"budget_usd":{"type":"number","nullable":true,"description":"Optional campaign budget in USD. Used for pacing and budget\nalerts but does not hard-stop delivery. Set to null for\nuncapped campaigns.\n","minimum":0.01,"example":2500},"targeting":{"type":"object","description":"Targeting criteria for matching screens. All criteria are combined\nwith AND logic. Omitted fields match all values.\n\nCompound targeting fields (`daypart`, `geo_lat`/`geo_lng`/`geo_radius_km`,\n`audience_segment_ids`, `blocked_iab_categories`, `blocked_advertiser_domains`,\n`frequency_cap_per_device_per_day`, `budget_pacing`) are enforced server-side\nat waterfall-mediation time — bids on screens that fail any filter are\ndropped before reaching delivery.\n","properties":{"venue_type":{"type":"string","description":"Target venue type","example":"retail"},"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code","example":"US"},"state":{"type":"string","description":"State or province code","example":"NY"},"city":{"type":"string","description":"City name","example":"New York"},"daypart":{"type":"array","nullable":true,"description":"Array of daypart windows. Each row narrows delivery to a single\nday-of-week + hour range (inclusive). Multiple rows are OR'd.\nEmpty array or null means always-on.\n","items":{"type":"object","required":["dow","hour_start","hour_end"],"properties":{"dow":{"type":"integer","minimum":0,"maximum":6,"description":"Day of week (0=Sunday..6=Saturday)"},"hour_start":{"type":"integer","minimum":0,"maximum":23},"hour_end":{"type":"integer","minimum":0,"maximum":23,"description":"Inclusive end hour; must be >= hour_start"}}}},"geo_lat":{"type":"number","nullable":true,"minimum":-90,"maximum":90,"description":"Latitude of geo-radius targeting center. Required if\n`geo_lng` or `geo_radius_km` are set.\n"},"geo_lng":{"type":"number","nullable":true,"minimum":-180,"maximum":180,"description":"Longitude of geo-radius targeting center. Required if\n`geo_lat` or `geo_radius_km` are set.\n"},"geo_radius_km":{"type":"number","nullable":true,"exclusiveMinimum":0,"description":"Radius in kilometers from (`geo_lat`, `geo_lng`). Required\nif `geo_lat` or `geo_lng` are set. Screens missing\nlat/lng metadata are excluded when geo radius is configured.\n"},"audience_segment_ids":{"type":"array","nullable":true,"description":"Required audience segment IDs. Each ID must resolve in\nthe `@trillboards/iab-taxonomy` package — either an IAB\nAudience Taxonomy 1.1 leaf (segtax=4) or a Trillboards\nsegment (segtax=600). Bids on screens whose live\naudience signals do NOT intersect this list are dropped.\n","items":{"type":"string","example":"iab:653"}},"blocked_iab_categories":{"type":"array","nullable":true,"description":"IAB content categories the campaign WILL NOT serve next\nto. Compared against the incoming bid's OpenRTB `bid.cat`;\nany intersection blocks the bid.\n","items":{"type":"string","example":"IAB7-39"}},"blocked_advertiser_domains":{"type":"array","nullable":true,"description":"Lowercase advertiser hostnames blocked for competitive\nseparation. Used by the brand-safety layer.\n","items":{"type":"string","example":"competitor.com"}},"frequency_cap_per_device_per_day":{"type":"integer","nullable":true,"minimum":1,"description":"Maximum impressions per device per UTC day. NULL means\nuncapped.\n"},"budget_pacing":{"type":"string","nullable":true,"enum":["asap","even","front_loaded"],"description":"Budget pacing model. `asap` = spend immediately; `even`\n= uniform across schedule; `front_loaded` = more early\nthen taper. Default null = asap.\n"}}}}},"example":{"creative_id":"6620abc123def456789abcde","name":"Summer Retail Push - NYC","budget_usd":2500,"targeting":{"country":"US","state":"NY","venue_type":"retail","daypart":[{"dow":1,"hour_start":9,"hour_end":17}],"geo_lat":40.7128,"geo_lng":-74.006,"geo_radius_km":10,"audience_segment_ids":["iab:653"],"blocked_iab_categories":["IAB7-39"],"frequency_cap_per_device_per_day":5,"budget_pacing":"even"}}}}},"responses":{"201":{"description":"Campaign created with matching screen list","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"campaign":{"type":"object","description":"Campaign details","properties":{"creative_id":{"type":"string","description":"Creative assigned to this campaign","example":"6620abc123def456789abcde"},"name":{"type":"string","description":"Campaign name","example":"Summer Retail Push - NYC"},"budget_usd":{"type":"number","nullable":true,"description":"Campaign budget (null if uncapped)","example":2500},"targeting":{"type":"object","description":"Targeting criteria applied","properties":{"venue_type":{"type":"string","example":"retail"},"country":{"type":"string","example":"US"},"state":{"type":"string","example":"NY"},"city":{"type":"string","nullable":true,"example":null}}},"status":{"type":"string","description":"Campaign status","example":"active"}}},"matching_screens":{"type":"integer","description":"Total number of screens matching the targeting criteria.\nMay exceed the number of screens in the `screens` array\n(which is capped at 20).\n","example":87},"screens":{"type":"array","description":"Up to 20 matching screens. Use these screen_id values with\n`POST /campaigns/assign`. For the full list, query `/adslots`\nwith the same targeting filters.\n","items":{"type":"object","properties":{"screen_id":{"type":"string","description":"Screen identifier for assignment","example":"scr_7f8a9b0c1d2e3f4a"},"name":{"type":"string","description":"Human-readable screen name","example":"Macy's Herald Square - Entrance Display"},"venue_type":{"type":"string","description":"Venue classification","example":"retail"},"country":{"type":"string","description":"Country code","example":"US"},"city":{"type":"string","description":"City name","example":"New York"}}}},"next_step":{"type":"string","description":"Guidance on the next step — calling the assign endpoint\nwith selected screen IDs.\n","example":"Call POST /openrtb/v2/campaigns/assign with creative_id and screen_ids to place your creative on specific screens."}}}}},"example":{"success":true,"data":{"campaign":{"creative_id":"6620abc123def456789abcde","name":"Summer Retail Push - NYC","budget_usd":2500,"targeting":{"venue_type":"retail","country":"US","state":"NY","city":null},"status":"active"},"matching_screens":87,"screens":[{"screen_id":"scr_7f8a9b0c1d2e3f4a","name":"Macy's Herald Square - Entrance Display","venue_type":"retail","country":"US","city":"New York"},{"screen_id":"scr_2a3b4c5d6e7f8a9b","name":"Nordstrom Flagship - Shoe Dept","venue_type":"retail","country":"US","city":"New York"},{"screen_id":"scr_1d2e3f4a5b6c7d8e","name":"Whole Foods Columbus Circle - Checkout","venue_type":"retail","country":"US","city":"New York"}],"next_step":"Call POST /openrtb/v2/campaigns/assign with creative_id and screen_ids to place your creative on specific screens."}}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"creative_id and name are required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Creative not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Creative 6620abc123def456789abcde not found"}}}},"422":{"description":"Creative is not approved. Only creatives that have passed AI moderation\ncan be used in campaigns. Check `GET /creatives/{id}` for moderation status.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Creative 6620abc123def456789abcde has status 'pending_moderation'. Only approved creatives can be used in campaigns."}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/campaigns/assign":{"post":{"operationId":"assignToScreens","summary":"Place creative on specific screens","description":"Assigns an approved creative to specific screens. Creates screen allocations\nthat make the ad visible in each screen's content playlist.\n\n**Screens pick up new assignments within 30-60 seconds** via their content\nrefresh cycle. No manual intervention is needed on the screen side.\n\n**Scheduling:** Optionally provide date and time ranges to control when the\ncreative is shown. If omitted, the creative runs indefinitely during all hours.\n\n**Limits:** Maximum 100 screen IDs per call. For larger deployments, make\nmultiple calls. Screen IDs come from the `/adslots` or `/campaigns` response.\n\n**Rate limit:** 100 requests/minute.\n","tags":["Campaigns"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["creative_id","screen_ids"],"properties":{"creative_id":{"type":"string","description":"ID of an approved creative to assign","example":"6620abc123def456789abcde"},"screen_ids":{"type":"array","description":"Array of screen IDs to assign the creative to. Minimum 1,\nmaximum 100 per request. Get screen IDs from `/adslots`,\n`/campaigns`, or `/discover`.\n","items":{"type":"string"},"minItems":1,"maxItems":100,"example":["scr_7f8a9b0c1d2e3f4a","scr_2a3b4c5d6e7f8a9b","scr_1d2e3f4a5b6c7d8e"]},"start_date":{"type":"string","format":"date","description":"Campaign start date (YYYY-MM-DD). If omitted, starts immediately.\n","example":"2026-04-10"},"end_date":{"type":"string","format":"date","description":"Campaign end date (YYYY-MM-DD). If omitted, runs indefinitely\nuntil manually stopped.\n","example":"2026-04-17"},"start_time":{"type":"string","description":"Daily start time in 24-hour format (HH:MM). The creative will only\nbe shown after this time each day. Default is \"00:00\" (midnight).\n","pattern":"^\\d{2}:\\d{2}$","default":"00:00","example":"08:00"},"end_time":{"type":"string","description":"Daily end time in 24-hour format (HH:MM). The creative will only\nbe shown before this time each day. Default is \"23:59\".\n","pattern":"^\\d{2}:\\d{2}$","default":"23:59","example":"22:00"}}},"example":{"creative_id":"6620abc123def456789abcde","screen_ids":["scr_7f8a9b0c1d2e3f4a","scr_2a3b4c5d6e7f8a9b","scr_1d2e3f4a5b6c7d8e"],"start_date":"2026-04-10","end_date":"2026-04-17","start_time":"08:00","end_time":"22:00"}}}},"responses":{"201":{"description":"Screens assigned successfully. Allocations are created immediately and\nscreens will begin showing the creative within 30-60 seconds.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"creative_id":{"type":"string","description":"Creative that was assigned","example":"6620abc123def456789abcde"},"assigned":{"type":"integer","description":"Number of screens successfully assigned","example":3},"failed":{"type":"integer","description":"Number of screens that could not be assigned (e.g., screen\nnot found, screen offline, screen already at max allocation).\n","example":0},"schedule":{"type":"object","description":"The schedule applied to all assignments","properties":{"start_date":{"type":"string","description":"Campaign start date","example":"2026-04-10"},"end_date":{"type":"string","description":"Campaign end date","example":"2026-04-17"},"start_time":{"type":"string","description":"Daily start time","example":"08:00"},"end_time":{"type":"string","description":"Daily end time","example":"22:00"}}},"assignments":{"type":"array","description":"Individual assignment results per screen","items":{"type":"object","properties":{"screen_id":{"type":"string","description":"Screen that was assigned","example":"scr_7f8a9b0c1d2e3f4a"},"allocation_id":{"type":"string","description":"Unique allocation ID. Use this to reference or cancel\nspecific screen assignments.\n","example":"alloc_9a8b7c6d5e4f3a2b"},"status":{"type":"string","description":"Assignment status","example":"active"}}}},"note":{"type":"string","description":"Informational message about content delivery timing","example":"Screens will begin showing this creative within their next content refresh cycle (~30-60 seconds)."}}}}},"example":{"success":true,"data":{"creative_id":"6620abc123def456789abcde","assigned":3,"failed":0,"schedule":{"start_date":"2026-04-10","end_date":"2026-04-17","start_time":"08:00","end_time":"22:00"},"assignments":[{"screen_id":"scr_7f8a9b0c1d2e3f4a","allocation_id":"alloc_9a8b7c6d5e4f3a2b","status":"active"},{"screen_id":"scr_2a3b4c5d6e7f8a9b","allocation_id":"alloc_1b2c3d4e5f6a7b8c","status":"active"},{"screen_id":"scr_1d2e3f4a5b6c7d8e","allocation_id":"alloc_4d5e6f7a8b9c0d1e","status":"active"}],"note":"Screens will begin showing this creative within their next content refresh cycle (~30-60 seconds)."}}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"creative_id and screen_ids are required. screen_ids must be an array of 1-100 screen IDs."}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"description":"Creative not approved or not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Creative 6620abc123def456789abcde is not approved for assignment"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/deals":{"get":{"operationId":"listDeals","summary":"List active deals for your DSP","description":"Returns all active deals available to the authenticated DSP seat. Deals are\nfiltered by your seat ID — you only see deals where your DSP has been\nexplicitly invited or deals that are open to all registered DSPs.\n\n**Deal types:**\n- **PMP** — Private marketplace deal with floor price\n- **Preferred** — Preferred deal with fixed pricing and priority\n- **Guaranteed** — Guaranteed delivery with committed impression volume\n","tags":["Deals"],"responses":{"200":{"description":"List of active deals","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","description":"Array of active deals","items":{"type":"object","properties":{"deal_id":{"type":"string","description":"Unique deal identifier (use in bid.dealid)","example":"deal_retail_q2_2026"},"deal_type":{"type":"string","description":"Type of deal","enum":["pmp","preferred","guaranteed"],"example":"pmp"},"floor_cpm":{"type":"number","description":"Minimum CPM price in USD","example":5},"status":{"type":"string","description":"Deal status","enum":["active","paused","expired"],"example":"active"},"impression_cap":{"type":"integer","nullable":true,"description":"Maximum impressions for this deal (null = uncapped)","example":500000},"impressions_delivered":{"type":"integer","description":"Impressions delivered so far","example":127430},"eligible_venue_types":{"type":"array","nullable":true,"description":"Venue types this deal applies to (null = all venues)","items":{"type":"string"},"example":["retail","restaurant"]},"eligible_countries":{"type":"array","nullable":true,"description":"Countries this deal applies to (null = all countries)","items":{"type":"string"},"example":["US","CA"]},"start_date":{"type":"string","format":"date","description":"Deal start date","example":"2026-04-01"},"end_date":{"type":"string","format":"date","nullable":true,"description":"Deal end date (null = no expiration)","example":"2026-06-30"},"created_at":{"type":"string","format":"date-time","description":"When the deal was created","example":"2026-03-28T09:00:00.000Z"}}}}}},"example":{"success":true,"data":[{"deal_id":"deal_retail_q2_2026","deal_type":"pmp","floor_cpm":5,"status":"active","impression_cap":500000,"impressions_delivered":127430,"eligible_venue_types":["retail","restaurant"],"eligible_countries":["US","CA"],"start_date":"2026-04-01","end_date":"2026-06-30","created_at":"2026-03-28T09:00:00.000Z"},{"deal_id":"deal_transit_premium_2026","deal_type":"preferred","floor_cpm":12,"status":"active","impression_cap":null,"impressions_delivered":45200,"eligible_venue_types":["transit","airport"],"eligible_countries":["US"],"start_date":"2026-03-15","end_date":null,"created_at":"2026-03-14T16:30:00.000Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/deals/{dealId}":{"get":{"operationId":"getDeal","summary":"Get deal details","description":"Returns full details for a specific deal, including impression delivery\nprogress, eligible venues and countries, and pricing terms.\n","tags":["Deals"],"parameters":[{"name":"dealId","in":"path","required":true,"description":"Deal identifier","schema":{"type":"string"},"example":"deal_retail_q2_2026"}],"responses":{"200":{"description":"Deal details","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"deal_id":{"type":"string","description":"Unique deal identifier","example":"deal_retail_q2_2026"},"deal_type":{"type":"string","enum":["pmp","preferred","guaranteed"],"example":"pmp"},"floor_cpm":{"type":"number","description":"Floor CPM in USD","example":5},"status":{"type":"string","enum":["active","paused","expired","pending"],"example":"active"},"impression_cap":{"type":"integer","nullable":true,"description":"Max impressions (null = uncapped)","example":500000},"impressions_delivered":{"type":"integer","description":"Impressions delivered against this deal","example":127430},"eligible_venue_types":{"type":"array","nullable":true,"items":{"type":"string"},"example":["retail","restaurant"]},"eligible_countries":{"type":"array","nullable":true,"items":{"type":"string"},"example":["US","CA"]},"start_date":{"type":"string","format":"date","example":"2026-04-01"},"end_date":{"type":"string","format":"date","nullable":true,"example":"2026-06-30"},"created_at":{"type":"string","format":"date-time","example":"2026-03-28T09:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","example":"2026-04-03T12:00:00.000Z"}}}}},"example":{"success":true,"data":{"deal_id":"deal_retail_q2_2026","deal_type":"pmp","floor_cpm":5,"status":"active","impression_cap":500000,"impressions_delivered":127430,"eligible_venue_types":["retail","restaurant"],"eligible_countries":["US","CA"],"start_date":"2026-04-01","end_date":"2026-06-30","created_at":"2026-03-28T09:00:00.000Z","updated_at":"2026-04-03T12:00:00.000Z"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Deal not found or not available to your DSP seat","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Deal deal_retail_q2_2026 not found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/deals/propose":{"post":{"operationId":"proposeDeal","summary":"Propose a new deal","description":"Propose a new PMP, preferred, or guaranteed deal. Proposed deals are created in\n`pending` status and require admin approval before becoming active.\n\n**Required:** `deal_id` (your unique identifier) and `floor_cpm` (minimum CPM > 0).\n\n**Deal lifecycle:** pending -> active -> (paused | expired)\n\nYou will receive a webhook notification (if configured) when the deal status changes.\n\n**Rate limit:** 100 requests/minute.\n","tags":["Deals"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["deal_id","floor_cpm"],"properties":{"deal_id":{"type":"string","description":"Your unique deal identifier. This becomes the `dealid` field in\nOpenRTB bid requests. Use a descriptive, URL-safe string.\n","minLength":2,"maxLength":128,"example":"deal_acme_summer_retail"},"floor_cpm":{"type":"number","description":"Minimum CPM price in USD. All bids under this deal must meet or\nexceed this floor price.\n","minimum":0.01,"example":8},"deal_type":{"type":"string","description":"Type of deal. Defaults to \"pmp\" if not specified.\n- `pmp` — Private marketplace with floor price\n- `preferred` — Fixed price, priority over open auction\n- `guaranteed` — Committed volume at fixed price\n","enum":["pmp","preferred","guaranteed"],"default":"pmp","example":"pmp"},"impression_cap":{"type":"integer","nullable":true,"description":"Maximum impressions for this deal. Set to null for uncapped.\nGuaranteed deals should always have an impression cap.\n","minimum":1,"example":250000},"start_date":{"type":"string","format":"date","description":"Deal start date (YYYY-MM-DD). Defaults to today.","example":"2026-04-10"},"end_date":{"type":"string","format":"date","nullable":true,"description":"Deal end date (YYYY-MM-DD). Null for no expiration.","example":"2026-07-10"},"eligible_venue_types":{"type":"array","nullable":true,"description":"Venue types this deal applies to. Null means all venue types.\n","items":{"type":"string"},"example":["retail","restaurant","coffee_shop"]},"eligible_countries":{"type":"array","nullable":true,"description":"ISO 3166-1 alpha-2 country codes this deal applies to. Null means\nall countries.\n","items":{"type":"string"},"example":["US"]}}},"example":{"deal_id":"deal_acme_summer_retail","floor_cpm":8,"deal_type":"pmp","impression_cap":250000,"start_date":"2026-04-10","end_date":"2026-07-10","eligible_venue_types":["retail","restaurant","coffee_shop"],"eligible_countries":["US"]}}}},"responses":{"201":{"description":"Deal proposed successfully. The deal is created in `pending` status and\nrequires admin approval before becoming active.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"deal_id":{"type":"string","description":"Your deal identifier","example":"deal_acme_summer_retail"},"deal_type":{"type":"string","example":"pmp"},"floor_cpm":{"type":"number","example":8},"status":{"type":"string","description":"All proposed deals start as \"pending\". Admin will review\nand activate.\n","example":"pending"},"impression_cap":{"type":"integer","nullable":true,"example":250000},"eligible_venue_types":{"type":"array","nullable":true,"items":{"type":"string"},"example":["retail","restaurant","coffee_shop"]},"eligible_countries":{"type":"array","nullable":true,"items":{"type":"string"},"example":["US"]},"start_date":{"type":"string","format":"date","example":"2026-04-10"},"end_date":{"type":"string","format":"date","nullable":true,"example":"2026-07-10"},"created_at":{"type":"string","format":"date-time","example":"2026-04-03T14:30:00.000Z"}}}}},"example":{"success":true,"data":{"deal_id":"deal_acme_summer_retail","deal_type":"pmp","floor_cpm":8,"status":"pending","impression_cap":250000,"eligible_venue_types":["retail","restaurant","coffee_shop"],"eligible_countries":["US"],"start_date":"2026-04-10","end_date":"2026-07-10","created_at":"2026-04-03T14:30:00.000Z"}}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"deal_id and floor_cpm are required. floor_cpm must be greater than 0."}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"Deal ID already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Deal ID \"deal_acme_summer_retail\" already exists"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/stats":{"get":{"operationId":"getDSPStats","summary":"Get 24-hour performance stats","description":"Returns aggregated performance metrics for the authenticated DSP over the\nlast 24 hours. Data is sourced from ClickHouse materialized views for\nsub-second response times.\n\n**Metrics returned:**\n- Total bids submitted\n- Total auction wins\n- Average CPM (clearing price)\n- Total revenue in USD\n- Average bid-to-win latency in milliseconds\n","tags":["Reporting"],"parameters":[{"name":"seat","in":"query","required":false,"description":"DSP seat identifier. If omitted, auto-filled from the authenticated\nAPI key. Only specify this if your API key has access to multiple seats.\n","schema":{"type":"string"},"example":"acme_ads"}],"responses":{"200":{"description":"24-hour performance metrics","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"seat":{"type":"string","description":"DSP seat identifier","example":"acme_ads"},"period":{"type":"string","description":"Reporting period","example":"24h"},"stats":{"type":"object","description":"Aggregated performance metrics","properties":{"total_bids":{"type":"integer","description":"Total bids submitted in the period","example":14523},"total_wins":{"type":"integer","description":"Total auction wins","example":3841},"avg_cpm":{"type":"number","description":"Average clearing CPM in USD","example":7.42},"total_revenue":{"type":"number","description":"Total revenue in USD","example":28508.22},"avg_latency_ms":{"type":"number","description":"Average bid-to-win processing latency in milliseconds","example":34.7}}}}},"example":{"seat":"acme_ads","period":"24h","stats":{"total_bids":14523,"total_wins":3841,"avg_cpm":7.42,"total_revenue":28508.22,"avg_latency_ms":34.7}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reports/summary":{"get":{"operationId":"getReportSummary","summary":"Aggregated performance summary","description":"Returns aggregated performance totals for a configurable lookback period.\nIncludes impression counts, fill rate, unique screen count, and average CPM.\n\n**Data source:** ClickHouse materialized views, refreshed every 5 minutes.\nMaximum lookback: 90 days.\n","tags":["Reporting"],"parameters":[{"name":"days","in":"query","required":false,"description":"Number of days to look back (default 7, maximum 90)","schema":{"type":"integer","default":7,"minimum":1,"maximum":90},"example":7}],"responses":{"200":{"description":"Performance summary for the specified period","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"period_days":{"type":"integer","description":"Number of days covered by this report","example":7},"total_impressions":{"type":"integer","description":"Total impressions served","example":28430},"unique_screens":{"type":"integer","description":"Number of unique screens that served impressions","example":87},"avg_cpm":{"type":"number","description":"Average CPM across all impressions in USD","example":7.42},"completed":{"type":"integer","description":"Number of impressions that completed (video played to end)","example":24167},"filled":{"type":"integer","description":"Number of ad requests that were filled (ad served)","example":28430},"no_fill":{"type":"integer","description":"Number of ad requests that went unfilled","example":3210},"fill_rate":{"type":"number","description":"Fill rate as a decimal (0.0 to 1.0)","minimum":0,"maximum":1,"example":0.8985}}},"example":{"period_days":7,"total_impressions":28430,"unique_screens":87,"avg_cpm":7.42,"completed":24167,"filled":28430,"no_fill":3210,"fill_rate":0.8985}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reports/screens":{"get":{"operationId":"getReportScreens","summary":"Per-screen performance breakdown","description":"Returns performance metrics broken down by individual screen. Useful for\nidentifying top-performing screens and optimizing screen targeting.\n\n**Data source:** ClickHouse materialized views. Results are sorted by\nimpressions descending (highest-performing screens first).\n","tags":["Reporting"],"parameters":[{"name":"days","in":"query","required":false,"description":"Number of days to look back (default 7, maximum 90)","schema":{"type":"integer","default":7,"minimum":1,"maximum":90},"example":7},{"name":"limit","in":"query","required":false,"description":"Maximum number of screens to return (default 50, maximum 200)","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50}],"responses":{"200":{"description":"Per-screen performance data","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"period_days":{"type":"integer","description":"Number of days covered","example":7},"data":{"type":"array","description":"Array of per-screen performance records, sorted by impressions\ndescending.\n","items":{"type":"object","properties":{"screen_id":{"type":"string","description":"Screen identifier","example":"scr_7f8a9b0c1d2e3f4a"},"impressions":{"type":"integer","description":"Total impressions on this screen","example":1247},"avg_cpm":{"type":"number","description":"Average CPM on this screen","example":8.12},"fills":{"type":"integer","description":"Number of filled ad requests","example":1247},"hours_active":{"type":"number","description":"Total hours the screen was active and serving ads","example":98.5}}}}}},"example":{"period_days":7,"data":[{"screen_id":"scr_7f8a9b0c1d2e3f4a","impressions":1247,"avg_cpm":8.12,"fills":1247,"hours_active":98.5},{"screen_id":"scr_2a3b4c5d6e7f8a9b","impressions":983,"avg_cpm":7.85,"fills":983,"hours_active":112},{"screen_id":"scr_1d2e3f4a5b6c7d8e","impressions":876,"avg_cpm":6.94,"fills":876,"hours_active":105.3}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reports/creatives":{"get":{"operationId":"getReportCreatives","summary":"Per-creative rollup for the authenticated DSP seat","description":"Returns a per-creative aggregate of delivery for the authenticated\nDSP seat over the requested lookback window. Sources from ClickHouse\n(`mv_creative_screen_daily`) with PostgreSQL rollup\n(`openrtb_creative_screen_daily`) as a graceful fallback when CH is\nunavailable. Results are sorted by `plays` descending.\n\nThe `source_provider` filter is applied implicitly: each DSP seat\nsees rows where `source_provider = inbound_dsp_<seat>` — the\ncanonical mapping established by the inbound bid cache and the\nwaterfall mediator.\n","tags":["Reporting"],"parameters":[{"name":"days","in":"query","required":false,"description":"Number of days to look back (default 7, maximum 90)","schema":{"type":"integer","default":7,"minimum":1,"maximum":90},"example":7},{"name":"limit","in":"query","required":false,"description":"Maximum number of creatives to return (default 50, maximum 200)","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50}],"responses":{"200":{"description":"Per-creative aggregate for the authenticated DSP seat","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"period_days":{"type":"integer","example":7},"seat":{"type":"string","nullable":true,"description":"DSP seat the rollup is scoped to","example":"acme_ads"},"count":{"type":"integer","example":12},"data":{"type":"array","description":"Per-creative rollup, sorted by `plays` descending.","items":{"type":"object","properties":{"creative_id":{"type":"string","example":"crv_4f8a9c1b0d2e3f4a"},"ad_id":{"type":"string","example":"ad_7b9c0d1e2f3a4b5c"},"plays":{"type":"integer","example":1247},"play_seconds":{"type":"number","example":18705.45},"unique_screens":{"type":"integer","example":38},"source_provider":{"type":"string","nullable":true,"example":"inbound_dsp_acme_ads"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reports/timeseries":{"get":{"operationId":"getReportTimeseries","summary":"Time-series performance data","description":"Returns chronological performance data points at hourly or daily granularity.\nUseful for graphing performance trends over time.\n\n**Data source:** ClickHouse materialized views. Each data point includes\nimpressions, average CPM, and active screen count for that time bucket.\n","tags":["Reporting"],"parameters":[{"name":"days","in":"query","required":false,"description":"Number of days to look back (default 7, maximum 90)","schema":{"type":"integer","default":7,"minimum":1,"maximum":90},"example":7},{"name":"granularity","in":"query","required":false,"description":"Time bucket granularity. Use \"hourly\" for detailed intraday data\n(up to 7 days) or \"daily\" for longer-range trends (up to 90 days).\nHourly granularity with days > 7 is automatically downgraded to daily.\n","schema":{"type":"string","enum":["hourly","daily"],"default":"daily"},"example":"daily"}],"responses":{"200":{"description":"Chronological performance data","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"period_days":{"type":"integer","description":"Number of days covered","example":7},"granularity":{"type":"string","description":"Time bucket granularity used","enum":["hourly","daily"],"example":"daily"},"data":{"type":"array","description":"Chronological data points, ordered by timestamp ascending.\nEach point represents one time bucket (hour or day).\n","items":{"type":"object","properties":{"timestamp":{"type":"string","format":"date-time","description":"Start of the time bucket in ISO 8601 format.\nFor daily granularity, this is midnight UTC.\n","example":"2026-03-28T00:00:00.000Z"},"impressions":{"type":"integer","description":"Impressions in this time bucket","example":4120},"avg_cpm":{"type":"number","description":"Average CPM in this time bucket","example":7.35},"active_screens":{"type":"integer","description":"Number of screens that served at least one impression","example":72}}}}}},"example":{"period_days":7,"granularity":"daily","data":[{"timestamp":"2026-03-28T00:00:00.000Z","impressions":4120,"avg_cpm":7.35,"active_screens":72},{"timestamp":"2026-03-29T00:00:00.000Z","impressions":3890,"avg_cpm":7.12,"active_screens":68},{"timestamp":"2026-03-30T00:00:00.000Z","impressions":4350,"avg_cpm":7.58,"active_screens":74},{"timestamp":"2026-03-31T00:00:00.000Z","impressions":3740,"avg_cpm":7.21,"active_screens":65},{"timestamp":"2026-04-01T00:00:00.000Z","impressions":4510,"avg_cpm":7.68,"active_screens":79},{"timestamp":"2026-04-02T00:00:00.000Z","impressions":3920,"avg_cpm":7.41,"active_screens":71},{"timestamp":"2026-04-03T00:00:00.000Z","impressions":3900,"avg_cpm":7.49,"active_screens":70}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/screens/{id}":{"get":{"operationId":"getScreenDetail","summary":"Comprehensive screen profile","description":"Returns a full profile for a single screen — physical size, spot config,\nvenue intelligence, live + historical audience data, VAS scoring, 30-day\nperformance metrics, reliability, device capabilities, programmatic settings,\nand brand-safety flags.\n\n**Data sources (in order of latency):**\n1. **Redis** — live audience signals (~10 s refresh)\n2. **PostgreSQL** — screen metadata, venue enrichment, placements\n3. **ClickHouse** — performance rollups (30-day fill, completion, funnel)\n\n**Caching:** 2-minute in-memory cache keyed by `screen_id`. The `X-Cache`\nresponse header is `HIT` when served from cache, `MISS` otherwise.\n\n**Rate limit category:** Inventory (200 req/min production).\n","tags":["Screens"],"parameters":[{"name":"id","in":"path","required":true,"description":"Screen identifier — the `mongo_earnerscreen_id` (24-character MongoDB\nObjectId) returned by `/adslots`, `/inventory/v1/screens`, or any\nreservation response. Must be at least 10 characters.\n","schema":{"type":"string","minLength":10},"example":"65a1b2c3d4e5f6a7b8c9d0e1"}],"responses":{"200":{"description":"Full screen profile with all enrichment sections populated.","headers":{"X-Cache":{"description":"Cache status (`HIT` from in-memory cache, `MISS` fresh).","schema":{"type":"string","enum":["HIT","MISS"],"example":"MISS"}},"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":200}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":198}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":45}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"$ref":"#/components/schemas/ScreenProfile"}}},"example":{"success":true,"data":{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","name":"Downtown Coffee Shop Display","physical_size":{"width_inches":37.5,"height_inches":21.1,"diagonal_inches":43,"confidence":"estimated"},"spot_config":{"display_mode":"fullscreen","concurrent_ad_slots":1,"spot_duration_sec":15,"min_spot_duration_sec":6,"ad_interval_sec":30,"spots_per_hour":120,"programmatic_share":1,"impressions_per_hour":120},"location":{"city":"New York","state":"NY","country":"US","zip":"10001","lat":40.7128,"lng":-74.006,"timezone":"America/New_York"},"venue":{"type":"retail","subcategory":"coffee_shop","grandchild":null,"environment":"indoor","placement":"counter","neighborhood_class":"dense_urban","places_rating":4.6,"places_review_count":1245,"places_price_level":2,"quality_score":0.87,"nearby_amenities":{"restaurants":14,"shops":22,"bars":6,"cafes":9,"transit_stops":3,"theatres":1,"gyms":2,"total":57},"nearby_amenity_count":57},"audience":{"vas":{"avg_weighted":6.52,"avg_raw":5.48,"median":5.1,"p90":9.8,"tier":"premium","cpm_multiplier":1.4,"total_measurements":4820,"last_measurement":"2026-04-11T13:58:00.000Z"},"live":{"face_count":4,"attention_score":0.78,"dominant_emotion":"happy","crowd_density":12,"purchase_intent":"moderate","ad_receptivity":0.68,"emotional_engagement":0.72,"screen_engagement":0.81,"last_updated":"2026-04-11T13:59:45.000Z","data_quality":"live"},"coverage":"live","estimated_daily_impressions":480},"display":{"width_px":1920,"height_px":1080,"orientation":"landscape","sound_enabled":true},"device":{"make":"Amazon","model":"Fire TV Stick 4K Max","os":"android","os_version":"11","device_type":8,"connection_type":"wifi","video":{"max_duration_sec":30,"min_duration_sec":6,"mimes":["video/mp4"],"protocols":[2,3,5,6]},"capabilities":{"face_detection":true,"audio_classification":false,"sound_enabled":true}},"performance":{"fill_rate":0.867,"completion_rate":0.942,"total_impressions_30d":14250,"total_completions_30d":13423,"vast_funnel":{"requests":16430,"fills":14250,"completions":13423,"errors":185}},"reliability":{"online_now":true,"last_seen":"2026-04-11T13:59:58.000Z"},"programmatic":{"floor_cpm":2.5,"dynamic_floor_pricing":false,"auction_enabled":true,"auction_type":"first_price","allocation_pct":100,"header_bidding":{"enabled":false}},"brand_safety":{"max_age_rating":"PG-13","content_filtering":true,"blocked_categories":[],"allowed_sensitive":{"alcohol":false,"gambling":false,"political":false,"cannabis":false,"tobacco":false,"adult":false}}}}}}},"400":{"description":"Invalid screen ID. The path parameter must be at least 10 characters.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Invalid screen ID"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Screen not found. The screen either does not exist or has been soft-deleted.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Screen not found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/screens/availability":{"post":{"operationId":"getScreenAvailability","summary":"Bulk capacity and booking query","description":"Returns per-screen deliverable capacity, booked impressions, and remaining\navailability for a given date range. Use this before creating a reservation\nto confirm there is enough inventory to satisfy your flight.\n\n**What you get per screen:**\n- **Capacity** — Total impressions deliverable in the window, plus spots, hours,\n  and a forecast confidence (0-1).\n- **Booked** — Impressions already committed to active/scheduled placements.\n- **Available** — Remaining impressions and utilization percentage.\n- **Reliability** — Whether the screen is currently online and its last heartbeat.\n- **Daily breakdown** (optional) — Per-day capacity/booked/available when\n  `granularity=daily` is provided.\n\n**Caching:** 5-minute in-memory cache keyed by the hash of the screen ID list\nand date range. Serving from cache returns `X-Cache: HIT`.\n\n**Limits:** Maximum 50 screen IDs per request. Returns a 503 if the forecast\nservice is unavailable.\n\n**Rate limit category:** Inventory (200 req/min production).\n","tags":["Screens"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["screen_ids","start_date","end_date"],"properties":{"screen_ids":{"type":"array","description":"Array of screen IDs to check availability for (1-50 items).","minItems":1,"maxItems":50,"items":{"type":"string"},"example":["65a1b2c3d4e5f6a7b8c9d0e1","65a1b2c3d4e5f6a7b8c9d0e2"]},"start_date":{"type":"string","format":"date","description":"Flight start date (YYYY-MM-DD).","example":"2026-05-01"},"end_date":{"type":"string","format":"date","description":"Flight end date (YYYY-MM-DD). Inclusive.","example":"2026-05-14"},"start_time":{"type":"string","description":"Optional daily start time (HH:MM, 24-hour). Defaults to `00:00`.\nUsed when you only want to measure capacity within a daypart.\n","example":"09:00"},"end_time":{"type":"string","description":"Optional daily end time (HH:MM, 24-hour). Defaults to `23:59`.\n","example":"21:00"},"granularity":{"type":"string","description":"Response granularity:\n- `flight` — total capacity across the window (default)\n- `daily` — per-day breakdown in `daily_breakdown`\n","enum":["flight","daily"],"default":"flight","example":"daily"}}},"example":{"screen_ids":["65a1b2c3d4e5f6a7b8c9d0e1","65a1b2c3d4e5f6a7b8c9d0e2"],"start_date":"2026-05-01","end_date":"2026-05-14","start_time":"09:00","end_time":"21:00","granularity":"daily"}}}},"responses":{"200":{"description":"Per-screen availability breakdown with summary totals.","headers":{"X-Cache":{"description":"Cache status (`HIT` or `MISS`).","schema":{"type":"string","enum":["HIT","MISS"]}},"RateLimit-Limit":{"schema":{"type":"integer","example":200}},"RateLimit-Remaining":{"schema":{"type":"integer","example":199}},"RateLimit-Reset":{"schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"screens":{"type":"array","items":{"$ref":"#/components/schemas/ScreenAvailability"}},"summary":{"type":"object","properties":{"total_available_impressions":{"type":"integer","example":12540},"screens_available":{"type":"integer","example":2},"screens_fully_booked":{"type":"integer","example":0},"avg_utilization_pct":{"type":"number","example":28.4}}}}}}},"example":{"success":true,"data":{"screens":[{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","name":"Downtown Coffee Shop Display","timezone":"America/New_York","capacity":{"total_impressions":8400,"total_spots":8400,"impressions_per_hour":50,"hours":168,"confidence":0.92},"booked":{"impressions":2100,"active_placements":2,"reserved_impressions":0},"available":{"impressions":6300,"spots":6300,"utilization_pct":25},"reliability":{"online_now":true,"last_seen":"2026-04-11T13:59:58.000Z"},"daily_breakdown":[{"date":"2026-05-01","total_impressions":600,"booked_impressions":150,"reserved_impressions":0,"available_impressions":450}]},{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e2","name":"Midtown Gym Lobby","timezone":"America/New_York","capacity":{"total_impressions":7560,"total_spots":7560,"impressions_per_hour":45,"hours":168,"confidence":0.88},"booked":{"impressions":1320,"active_placements":1,"reserved_impressions":0},"available":{"impressions":6240,"spots":6240,"utilization_pct":17.5},"reliability":{"online_now":true,"last_seen":"2026-04-11T13:59:12.000Z"}}],"summary":{"total_available_impressions":12540,"screens_available":2,"screens_fully_booked":0,"avg_utilization_pct":21.3}}}}}},"400":{"description":"Invalid request. Common causes: missing `screen_ids`/`start_date`/`end_date`,\nempty `screen_ids` array, or more than 50 screen IDs.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"screen_ids is required (array, 1-50 items)"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Forecast service unavailable. The downstream impression-forecast engine\nis offline; retry after a short delay.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Forecast service unavailable"}}}}}}},"/screens/audience":{"post":{"operationId":"getScreenAudience","summary":"Bulk audience profiles for up to 50 screens","description":"Returns structured audience intelligence for the specified screens. Combines\nthree data sources with graceful degradation — any subset may be null without\nfailing the overall request:\n\n1. **Redis live signals** — fresh face count, attention, emotion, purchase intent,\n   and ad receptivity (10-30 s refresh).\n2. **VAS rolling averages** — Verified Attention Seconds, CPM tier, multiplier.\n3. **PostgreSQL venue enrichment** — neighborhood classification, Places rating,\n   amenity density, foot-traffic level.\n\n**Per-screen shape:**\n- `data_quality`: `live` when Redis has fresh data, `historical` when only\n  VAS is available, `none` when no audience data exists for the screen.\n- `signals_age_ms`: milliseconds since the last Redis update (null for non-live).\n- `demographics`, `behavior`, `venue` — filtered by `signal_types` if provided.\n- `vas` is always included (core DSP signal).\n- `patterns` is returned only when `include_patterns=true`. Currently reserved\n  for a future ClickHouse-backed hourly aggregation; returns `null` today.\n\n**Limits:** Maximum 50 screen IDs per request.\n\n**Rate limit category:** Inventory (200 req/min production).\n","tags":["Screens"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["screen_ids"],"properties":{"screen_ids":{"type":"array","description":"Array of screen IDs (1-50 items).","minItems":1,"maxItems":50,"items":{"type":"string"},"example":["65a1b2c3d4e5f6a7b8c9d0e1","65a1b2c3d4e5f6a7b8c9d0e2"]},"signal_types":{"type":"array","description":"Optional filter for returned sections. When omitted, all sections\n(`demographics`, `behavior`, `venue`) are included. `vas` is always\nreturned.\n","items":{"type":"string","enum":["demographics","behavior","venue"]},"example":["demographics","behavior"]},"include_live":{"type":"boolean","description":"Include fresh Redis signals. Defaults to `true`. Set `false` to\nskip the Redis query and force historical-only output.\n","default":true,"example":true},"include_patterns":{"type":"boolean","description":"Request the `patterns` object (hourly breakdown). Reserved for a\nfuture ClickHouse-backed aggregation; returns `null` until shipped.\n","default":false,"example":false}}},"example":{"screen_ids":["65a1b2c3d4e5f6a7b8c9d0e1","65a1b2c3d4e5f6a7b8c9d0e2"],"signal_types":["demographics","behavior","venue"],"include_live":true,"include_patterns":false}}}},"responses":{"200":{"description":"Per-screen audience profiles.","headers":{"RateLimit-Limit":{"schema":{"type":"integer","example":200}},"RateLimit-Remaining":{"schema":{"type":"integer","example":199}},"RateLimit-Reset":{"schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"screens":{"type":"array","items":{"$ref":"#/components/schemas/ScreenAudienceProfile"}},"metadata":{"type":"object","properties":{"total_screens":{"type":"integer","example":2},"with_live_data":{"type":"integer","example":1},"with_historical_data":{"type":"integer","example":1},"with_no_data":{"type":"integer","example":0}}}}}}},"example":{"success":true,"data":{"screens":[{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","data_quality":"live","signals_age_ms":8200,"demographics":{"avg_face_count":4.2,"avg_attention_score":0.76,"avg_dwell_time_sec":12,"crowd_density":12,"income_level":"medium","group_composition":"couples"},"behavior":{"purchase_intent":"moderate","ad_receptivity":0.68,"emotional_engagement":0.72,"dominant_emotion":"happy","screen_engagement":0.81,"shopping_contexts":["coffee","breakfast"],"brand_mentions":["Starbucks"]},"venue":{"neighborhood_class":"dense_urban","places_rating":4.6,"foot_traffic_level":"high","nearby_amenity_count":57},"vas":{"score":6.52,"tier":"premium","cpm_multiplier":1.4}},{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e2","data_quality":"historical","signals_age_ms":null,"demographics":{"avg_face_count":2.1,"avg_attention_score":0.62,"avg_dwell_time_sec":0,"crowd_density":null,"income_level":null,"group_composition":null},"behavior":{"purchase_intent":null,"ad_receptivity":null,"emotional_engagement":null,"dominant_emotion":null,"screen_engagement":null,"shopping_contexts":[],"brand_mentions":[]},"venue":{"neighborhood_class":"suburban_commercial","places_rating":4.2,"foot_traffic_level":null,"nearby_amenity_count":23},"vas":{"score":4.18,"tier":"standard","cpm_multiplier":1}}],"metadata":{"total_screens":2,"with_live_data":1,"with_historical_data":1,"with_no_data":0}}}}}},"400":{"description":"Invalid request. Common causes: missing `screen_ids`, empty array,\nmore than 50 IDs, or invalid `signal_types` value.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"screen_ids is required and must be a non-empty array"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/proof-of-play":{"get":{"operationId":"getProofOfPlay","summary":"Verified ad play records with tier breakdown","description":"Returns verified play records from `completed_impressions` so DSP buyers can\nreconcile billing against actual screen deliveries. Each record is tagged with\na `verification_tier`:\n\n- **`signed`** — Ed25519 signed proof-of-play from the screen (highest trust)\n- **`event_confirmed`** — VAST `complete` event fired by the IMA SDK\n- **`partner_reported`** — Partner SDK lifecycle completion report\n\nImpression callbacks are pixel fires, not completed plays, and are not\nreturned by this DSP-facing proof surface.\n\n**Required:** `start_date` and `end_date`. These are always included in the\nquery WHERE clause for partition pruning — the `completed_impressions` table\nis partitioned monthly, so omitting them would trigger a full scan and time out.\n\n**Rate limit category:** Reporting (100 req/min).\n","tags":["Proof of Play"],"parameters":[{"name":"start_date","in":"query","required":true,"description":"ISO 8601 timestamp — lower bound on `completed_at` (inclusive).","schema":{"type":"string","format":"date-time"},"example":"2026-05-01T00:00:00Z"},{"name":"end_date","in":"query","required":true,"description":"ISO 8601 timestamp — upper bound on `completed_at` (exclusive).","schema":{"type":"string","format":"date-time"},"example":"2026-05-14T23:59:59Z"},{"name":"screen_id","in":"query","required":false,"description":"Filter to a single screen (mongo_earnerscreen_id).","schema":{"type":"string"},"example":"65a1b2c3d4e5f6a7b8c9d0e1"},{"name":"creative_id","in":"query","required":false,"description":"Filter to a single creative.","schema":{"type":"string"},"example":"creative_summer_sale"},{"name":"campaign_id","in":"query","required":false,"description":"Filter to a single campaign (maps to `ad_id`).","schema":{"type":"string"},"example":"cmp_acme_q2_2026"},{"name":"verification_tier","in":"query","required":false,"description":"Restrict to a single verification tier.\n","schema":{"type":"string","enum":["signed","event_confirmed","partner_reported"]},"example":"signed"},{"name":"limit","in":"query","required":false,"description":"Rows to return (default 50, maximum 200).","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50},{"name":"offset","in":"query","required":false,"description":"Pagination offset (default 0).","schema":{"type":"integer","default":0,"minimum":0},"example":0}],"responses":{"200":{"description":"Proof records with tier-summary statistics.","headers":{"RateLimit-Limit":{"schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"schema":{"type":"integer","example":99}},"RateLimit-Reset":{"schema":{"type":"integer","example":55}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"proofs":{"type":"array","items":{"$ref":"#/components/schemas/ProofOfPlayRecord"}},"summary":{"type":"object","properties":{"total_plays":{"type":"integer","description":"Total play records matching the filters.","example":1420},"verified_signed":{"type":"integer","description":"Plays with Ed25519 signature (`proof_of_play`).","example":12},"verified_event":{"type":"integer","description":"Plays confirmed by VAST `complete` event.","example":142},"verified_partner_reported":{"type":"integer","description":"Plays confirmed by partner SDK lifecycle report.","example":1266},"givt_pass_rate":{"type":"number","minimum":0,"maximum":1,"description":"Fraction of plays that passed GIVT checks.","example":0.9943}}},"pagination":{"type":"object","properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":1420}}}}}}},"example":{"success":true,"data":{"proofs":[{"play_id":"play_01HS8A3T0X7V4Q9P2MWKJ5N6BC","screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","creative_id":"creative_summer_sale","played_at":"2026-05-01T14:22:31.000Z","duration_seconds":15,"verification_tier":"signed","signature":"ed25519:AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=","display_mode":"fullscreen","display_area_ratio":1,"givt_passed":true},{"play_id":"play_01HS8A3V1Y8W5R0Q3NXLKM6OD","screen_id":"65a1b2c3d4e5f6a7b8c9d0e2","creative_id":"creative_summer_sale","played_at":"2026-05-01T14:22:45.000Z","duration_seconds":15,"verification_tier":"event_confirmed","signature":null,"display_mode":"fullscreen","display_area_ratio":1,"givt_passed":true}],"summary":{"total_plays":1420,"verified_signed":12,"verified_event":142,"verified_partner_reported":1266,"givt_pass_rate":0.9943},"pagination":{"limit":50,"offset":0,"total":1420}}}}}},"400":{"description":"Invalid request. Common causes: missing `start_date`/`end_date`, invalid\nISO date strings, `start_date` after `end_date`, or invalid\n`verification_tier` value.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"start_date and end_date query parameters are required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reservations":{"post":{"operationId":"createReservation","summary":"Hold capacity on up to 10 screens","description":"Holds screen capacity for a configurable TTL (default 15 minutes). Each screen\nin `screen_ids` gets its own reservation record; the response contains the\nfull list plus an aggregated summary.\n\n**Capacity estimation.** Each reservation's `reserved_impressions` is the\ndeliverable impression count from the impression forecast engine for that\nscreen and date range. `estimated_cost_usd = (reserved_impressions × floor_cpm) / 1000`.\n\n**Limits.** Maximum 10 screen IDs per request. Maximum 10 active reservations\nper DSP seat — exceeding this returns HTTP 429.\n\n**Auto-expiration.** Held reservations auto-expire once `expires_at` passes.\nListing reservations lazily flips expired ones, so they will appear as\n`status=expired` on subsequent reads.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["Reservations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["screen_ids","start_date","end_date"],"properties":{"screen_ids":{"type":"array","description":"Screens to hold capacity on (1-10 items).","minItems":1,"maxItems":10,"items":{"type":"string"},"example":["65a1b2c3d4e5f6a7b8c9d0e1"]},"start_date":{"type":"string","format":"date","example":"2026-05-01"},"end_date":{"type":"string","format":"date","example":"2026-05-14"},"start_time":{"type":"string","description":"Optional daily start time (HH:MM). Defaults to `00:00`.","example":"09:00"},"end_time":{"type":"string","description":"Optional daily end time (HH:MM). Defaults to `23:59`.","example":"21:00"},"creative_id":{"type":"string","description":"Optional creative ID to associate with the reservation. Used when\nthe DSP has already uploaded the creative and wants to pre-link it\nto the reservation for audit.\n","example":"creative_summer_sale"},"campaign_name":{"type":"string","description":"Optional human-readable campaign label for audit logs.","example":"Summer Sale Launch"}}},"example":{"screen_ids":["65a1b2c3d4e5f6a7b8c9d0e1"],"start_date":"2026-05-01","end_date":"2026-05-14","start_time":"09:00","end_time":"21:00","creative_id":"creative_summer_sale","campaign_name":"Summer Sale Launch"}}}},"responses":{"201":{"description":"Reservations created successfully. Each returned reservation is in\n`held` status with an `expires_at` in the near future (default 15 min).\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"reservations":{"type":"array","items":{"$ref":"#/components/schemas/Reservation"}},"summary":{"type":"object","properties":{"total_reserved_impressions":{"type":"integer","example":8400},"total_estimated_cost_usd":{"type":"number","example":21},"expires_at":{"type":"string","format":"date-time","example":"2026-04-11T14:15:00.000Z"}}}}}}},"example":{"success":true,"data":{"reservations":[{"reservation_id":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC","screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","screen_name":"Downtown Coffee Shop Display","reserved_impressions":8400,"estimated_cost_usd":21,"status":"held","held_at":"2026-04-11T14:00:00.000Z","expires_at":"2026-04-11T14:15:00.000Z","ttl_seconds":900}],"summary":{"total_reserved_impressions":8400,"total_estimated_cost_usd":21,"expires_at":"2026-04-11T14:15:00.000Z"}}}}}},"400":{"description":"Invalid request. Common causes: missing/empty `screen_ids`, more than\n10 screens, or missing date bounds.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"screen_ids required (array, 1-10 items)"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No valid screens found. None of the supplied `screen_ids` matched an\nactive screen record.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"No valid screens found"}}}},"429":{"description":"Either the DSP seat has exceeded its active-reservation quota (max 10)\nor the campaigns rate limit was hit.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Maximum 10 active reservations per DSP. Currently: 10"}}}},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Database write unavailable. The PG write pool is not ready; retry shortly.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Database write unavailable"}}}}}},"get":{"operationId":"listReservations","summary":"List reservations for the authenticated DSP seat","description":"Returns all reservations (held, confirmed, expired, released) for your DSP\nseat, ordered by creation time descending. Stale held reservations are\nlazily flipped to `expired` on read, so listing them also triggers housekeeping.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["Reservations"],"parameters":[{"name":"limit","in":"query","required":false,"description":"Page size (default 50, max 200).","schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"example":50},{"name":"offset","in":"query","required":false,"description":"Pagination offset.","schema":{"type":"integer","default":0,"minimum":0},"example":0}],"responses":{"200":{"description":"List of reservations for the current DSP seat.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"reservations":{"type":"array","items":{"$ref":"#/components/schemas/Reservation"}}}},"pagination":{"type":"object","properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"count":{"type":"integer","example":3}}}}},"example":{"success":true,"data":{"reservations":[{"reservation_id":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC","screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","screen_name":"Downtown Coffee Shop Display","creative_id":"creative_summer_sale","campaign_name":"Summer Sale Launch","start_date":"2026-05-01","end_date":"2026-05-14","start_time":"09:00","end_time":"21:00","reserved_impressions":8400,"floor_cpm":2.5,"estimated_cost_usd":21,"status":"held","held_at":"2026-04-11T14:00:00.000Z","expires_at":"2026-04-11T14:15:00.000Z","ttl_seconds":420,"confirmed_at":null,"released_at":null,"placement_id":null}]},"pagination":{"limit":50,"offset":0,"count":1}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/reservations/{id}":{"get":{"operationId":"getReservation","summary":"Fetch a single reservation","description":"Returns a single reservation by ID. Verifies ownership — only the DSP seat\nthat created the reservation can read it. Auto-expires the reservation on\nread if the TTL has passed.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["Reservations"],"parameters":[{"name":"id","in":"path","required":true,"description":"Reservation ID returned from `POST /reservations`.","schema":{"type":"string"},"example":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC"}],"responses":{"200":{"description":"Reservation detail.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"$ref":"#/components/schemas/Reservation"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"This reservation does not belong to your DSP seat.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Not authorized to view this reservation"}}}},"404":{"description":"Reservation not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation not found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}},"delete":{"operationId":"releaseReservation","summary":"Release a held reservation (free capacity)","description":"Transitions a reservation from `held` to `released`, freeing its capacity for\nother buyers immediately. Confirmed, expired, or already-released reservations\ncannot be released.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["Reservations"],"parameters":[{"name":"id","in":"path","required":true,"description":"Reservation ID to release.","schema":{"type":"string"},"example":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC"}],"responses":{"200":{"description":"Reservation released.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"reservation_id":{"type":"string","example":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"status":{"type":"string","enum":["released"],"example":"released"}}}}},"example":{"success":true,"data":{"reservation_id":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC","status":"released"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Reservation not owned by this DSP seat.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Not authorized"}}}},"404":{"description":"Reservation not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation not found"}}}},"409":{"description":"Reservation is not currently held (already confirmed, expired, or released).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation is not in held status"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Database write unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Database write unavailable"}}}}}}},"/reservations/{id}/confirm":{"post":{"operationId":"confirmReservation","summary":"Convert a held reservation to a confirmed placement","description":"Transitions a reservation from `held` to `confirmed`. Only held reservations\nwhose TTL has not yet elapsed can be confirmed. Returns `410 Gone` if the\nreservation has already expired.\n\nAfter confirmation, call `POST /openrtb/v2/campaigns/assign` to pin the\ncreative to the reserved screens.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["Reservations"],"parameters":[{"name":"id","in":"path","required":true,"description":"Reservation ID to confirm.","schema":{"type":"string"},"example":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC"}],"responses":{"200":{"description":"Reservation confirmed.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"$ref":"#/components/schemas/Reservation"},"next_step":{"type":"string","description":"Suggested follow-up action.","example":"Use POST /openrtb/v2/campaigns/assign to place creative on reserved screens"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Reservation not owned by this DSP seat.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Not authorized"}}}},"404":{"description":"Reservation not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation not found"}}}},"409":{"description":"Reservation is not currently held (already confirmed, expired, or released).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation is expired, not held"}}}},"410":{"description":"Reservation has expired. Create a new reservation to continue.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Reservation has expired"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Database write unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Database write unavailable"}}}}}}},"/market/cpm-benchmarks":{"get":{"operationId":"getCpmBenchmarks","summary":"CPM percentile benchmarks by venue and geography","description":"Returns p25/median/p75/avg CPM percentiles per venue type, optionally\nfiltered by country, state, or daypart. Also returns a market overview with\ntotal SSP-enabled screens, average fill rate, and average completion rate.\n\n**Data source:** `bid_intelligence` and `openrtb_vast_screen_hourly` PG rollup\ntables. Cached in memory for 10 minutes per query.\n\n**Rate limit category:** Reporting (100 req/min).\n","tags":["Market Intelligence"],"parameters":[{"name":"venue_type","in":"query","required":false,"description":"Filter to a single venue category (e.g., `coffee_shop`, `gym`).","schema":{"type":"string"},"example":"coffee_shop"},{"name":"country","in":"query","required":false,"description":"ISO 3166-1 alpha-2 country code.","schema":{"type":"string"},"example":"US"},{"name":"state","in":"query","required":false,"description":"State or province filter.","schema":{"type":"string"},"example":"NY"},{"name":"granularity","in":"query","required":false,"description":"Row grouping:\n- `none` — single row per venue type (default)\n- `daypart` — one row per venue type × daypart\n- `daily` — one row per venue type × day\n","schema":{"type":"string","enum":["none","daypart","daily"],"default":"none"},"example":"daypart"},{"name":"days","in":"query","required":false,"description":"Lookback window in days (default 30, maximum 365).","schema":{"type":"integer","default":30,"minimum":1,"maximum":365},"example":30}],"responses":{"200":{"description":"CPM benchmarks and market overview.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"benchmarks":{"type":"array","items":{"$ref":"#/components/schemas/CpmBenchmark"}},"market_overview":{"type":"object","properties":{"total_screens_ssp":{"type":"integer","example":8420},"avg_fill_rate":{"type":"number","minimum":0,"maximum":1,"example":0.87},"avg_completion_rate":{"type":"number","minimum":0,"maximum":1,"example":0.94}}}}}}},"example":{"success":true,"data":{"benchmarks":[{"venue_type":"coffee_shop","cpm":{"p25":4.5,"median":7.25,"p75":10.8,"avg":7.42},"sample_size":14520,"period":"30d","country":"US"},{"venue_type":"gym","cpm":{"p25":3.8,"median":5.9,"p75":8.2,"avg":6.08},"sample_size":8340,"period":"30d","country":"US"}],"market_overview":{"total_screens_ssp":8420,"avg_fill_rate":0.87,"avg_completion_rate":0.94}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/market/demand-analysis":{"get":{"operationId":"getDemandAnalysis","summary":"Top categories, daypart curves, segment premiums","description":"Returns demand-side analytics: top advertiser categories by bid volume, daypart\ndemand curves (avg CPM + fill rate by morning/afternoon/evening/late_night),\nand segment CPM premium lift (if the segment correlation table is populated).\n\n**Data source:** `bid_intelligence` and `openrtb_vast_screen_hourly` PG rollup\ntables. Segment data is sourced from `segment_cpm_correlation` when available\n(graceful degradation returns an empty `segment_premiums` array otherwise).\n\n**Rate limit category:** Reporting (100 req/min).\n","tags":["Market Intelligence"],"parameters":[{"name":"days","in":"query","required":false,"description":"Lookback window in days (default 30, maximum 365).","schema":{"type":"integer","default":30,"minimum":1,"maximum":365},"example":30},{"name":"limit","in":"query","required":false,"description":"Max top categories to return (default 10, maximum 100).","schema":{"type":"integer","default":10,"minimum":1,"maximum":100},"example":10}],"responses":{"200":{"description":"Demand analysis breakdown.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"top_categories":{"type":"array","items":{"type":"object","properties":{"category":{"type":"string","example":"IAB19_Technology_Computing"},"bid_count":{"type":"integer","example":28450},"avg_cpm":{"type":"number","example":8.32}}}},"daypart_curves":{"type":"array","items":{"$ref":"#/components/schemas/DaypartCurve"}},"segment_premiums":{"type":"array","items":{"type":"object","properties":{"segment":{"type":"string","example":"high_attention"},"cpm_lift_pct":{"type":"number","example":38.4},"sample_size":{"type":"integer","example":1240}}}}}}}},"example":{"success":true,"data":{"top_categories":[{"category":"IAB19_Technology_Computing","bid_count":28450,"avg_cpm":8.32},{"category":"IAB8_Food_Drink","bid_count":21080,"avg_cpm":6.12},{"category":"IAB7_Health_Fitness","bid_count":18420,"avg_cpm":5.74}],"daypart_curves":[{"daypart":"morning","avg_cpm":5.84,"fill_rate":0.82,"bid_volume":14250},{"daypart":"afternoon","avg_cpm":7.12,"fill_rate":0.88,"bid_volume":19840},{"daypart":"evening","avg_cpm":9.28,"fill_rate":0.91,"bid_volume":22040},{"daypart":"late_night","avg_cpm":4.1,"fill_rate":0.64,"bid_volume":3820}],"segment_premiums":[{"segment":"high_attention","cpm_lift_pct":38.4,"sample_size":1240},{"segment":"live_faces_gt_5","cpm_lift_pct":22.1,"sample_size":840}]}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/analytics/funnel":{"get":{"operationId":"getVastFunnel","summary":"VAST event funnel (requests → renders → completions)","description":"Returns a VAST event funnel: `requests → bids_received → renders_started →\nplays_completed`, plus computed rates (`bid_rate`, `fill_rate`,\n`start_to_complete`), a top-50 per-screen breakdown, and optional timeseries\nat daily/hourly granularity.\n\n**Data source:** `openrtb_vast_screen_hourly` PG rollup table (OLTP-safe,\nrefreshed every 5 minutes from ClickHouse). In-memory cache: 5 minutes per\nquery fingerprint.\n\n**Funnel column mapping:**\n- `request_count` → `requests`\n- `response_count` → `bids_received`\n- `filled_count` → `renders_started` (ad began playing)\n- `completed_count` → `plays_completed` (ad played to full completion)\n\n**Rate limit category:** Reporting (100 req/min).\n","tags":["Analytics"],"parameters":[{"name":"start_date","in":"query","required":true,"description":"Flight start date (YYYY-MM-DD).","schema":{"type":"string","format":"date"},"example":"2026-05-01"},{"name":"end_date","in":"query","required":true,"description":"Flight end date (YYYY-MM-DD). Inclusive.","schema":{"type":"string","format":"date"},"example":"2026-05-14"},{"name":"screen_ids","in":"query","required":false,"description":"Comma-separated list of screen IDs to restrict the funnel to.","schema":{"type":"string"},"example":"65a1b2c3d4e5f6a7b8c9d0e1,65a1b2c3d4e5f6a7b8c9d0e2"},{"name":"creative_id","in":"query","required":false,"description":"Reserved for future use (creative-level funnel filter).","schema":{"type":"string"}},{"name":"granularity","in":"query","required":false,"description":"Timeseries granularity:\n- `flight` — no timeseries, only aggregate + per-screen (default)\n- `daily` — one bucket per day\n- `hourly` — one bucket per hour\n","schema":{"type":"string","enum":["flight","daily","hourly"],"default":"flight"},"example":"daily"}],"responses":{"200":{"description":"Funnel, per-screen breakdown, and optional timeseries.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"funnel":{"$ref":"#/components/schemas/FunnelStats"},"by_screen":{"type":"array","items":{"type":"object","properties":{"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"requests":{"type":"integer","example":1450},"completions":{"type":"integer","example":1180},"fill_rate":{"type":"number","example":0.876},"completion_rate":{"type":"number","example":0.928}}}},"timeseries":{"type":"array","items":{"type":"object","properties":{"period":{"type":"string","format":"date-time","example":"2026-05-01T00:00:00.000Z"},"requests":{"type":"integer","example":2850},"completions":{"type":"integer","example":2340},"fill_rate":{"type":"number","example":0.864}}}}}}}},"example":{"success":true,"data":{"funnel":{"requests":28450,"bids_received":26210,"renders_started":24890,"plays_completed":22140,"no_bids":1820,"errors":420,"bid_rate":0.921,"fill_rate":0.875,"start_to_complete":0.889},"by_screen":[{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e1","requests":1450,"completions":1180,"fill_rate":0.876,"completion_rate":0.928},{"screen_id":"65a1b2c3d4e5f6a7b8c9d0e2","requests":1380,"completions":1105,"fill_rate":0.862,"completion_rate":0.919}],"timeseries":[{"period":"2026-05-01T00:00:00.000Z","requests":2850,"completions":2340,"fill_rate":0.864},{"period":"2026-05-02T00:00:00.000Z","requests":2910,"completions":2410,"fill_rate":0.872}]}}}}},"400":{"description":"Invalid request. Common causes: missing `start_date`/`end_date`, invalid\ndate format (must be YYYY-MM-DD), or `start_date` after `end_date`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"start_date and end_date are required (YYYY-MM-DD)"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/webhooks":{"post":{"operationId":"registerWebhook","summary":"Register a webhook subscription","description":"Registers a new webhook for your DSP seat. Returns a unique `whsec_...`\nsecret **exactly once** — store it immediately. The secret is used to\nverify the `X-Trillboards-Signature` header on every delivery.\n\n**URL must be HTTPS.** HTTP URLs, missing hostnames, and non-URL strings are\nrejected.\n\n**Events must be from the supported list.** See the `events` enum below; any\nunknown event name is rejected with a 400 listing the allowed values.\n\n**Limits:** Maximum 10 active webhooks per DSP seat. Exceeding this returns\n429 with the current count.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["DSP Webhooks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","description":"HTTPS endpoint that will receive webhook deliveries.","example":"https://acme-ads.com/webhooks/trillboards"},"events":{"type":"array","description":"Non-empty list of event types to subscribe to. All entries must\nmatch one of the supported event names.\n","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEvent"},"example":["impression.delivered","campaign.completed","reservation.expired"]},"description":{"type":"string","description":"Optional human-readable note to identify the webhook in the list\nview (e.g., \"Staging environment\", \"Billing pipeline\").\n","example":"AdQuick staging pipeline"}}},"example":{"url":"https://acme-ads.com/webhooks/trillboards","events":["impression.delivered","campaign.completed","reservation.expired"],"description":"AdQuick staging pipeline"}}}},"responses":{"201":{"description":"Webhook registered successfully. The `secret` field is returned **exactly\nonce** — store it in a secrets manager now.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"webhook_id":{"type":"string","example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"url":{"type":"string","example":"https://acme-ads.com/webhooks/trillboards"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEvent"}},"secret":{"type":"string","description":"HMAC-SHA256 signing secret. Prefix `whsec_` followed by 32\nhex characters. Shown **exactly once** — store immediately.\n","example":"whsec_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"},"status":{"type":"string","enum":["active"],"example":"active"},"description":{"type":"string","nullable":true,"example":"AdQuick staging pipeline"},"created_at":{"type":"string","format":"date-time","example":"2026-04-11T14:00:00.000Z"}}}}},"example":{"success":true,"data":{"webhook_id":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC","url":"https://acme-ads.com/webhooks/trillboards","events":["impression.delivered","campaign.completed","reservation.expired"],"secret":"whsec_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","status":"active","description":"AdQuick staging pipeline","created_at":"2026-04-11T14:00:00.000Z"}}}}},"400":{"description":"Invalid request. Common causes: missing/malformed `url`, non-HTTPS URL,\nempty `events` array, or unknown event name.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"url must use https:// scheme"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"description":"Quota exceeded. Either the campaigns rate limit was hit, or the DSP seat\nalready has 10 active webhooks.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Maximum 10 webhooks per DSP seat. Currently: 10"}}}},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Database write unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Database write unavailable"}}}}}},"get":{"operationId":"listWebhooks","summary":"List webhook subscriptions for this DSP seat","description":"Returns all webhooks for your DSP seat, ordered by creation time descending.\n**Secrets are never returned** by this endpoint — they are only shown once at\ncreation time.\n\nEach entry includes delivery stats (`success_count`, `failure_count`,\n`last_triggered_at`, `last_error`) so you can monitor webhook health.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["DSP Webhooks"],"responses":{"200":{"description":"List of webhooks for the current DSP seat.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookSubscription"}}}},"example":{"success":true,"data":[{"webhook_id":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC","url":"https://acme-ads.com/webhooks/trillboards","events":["impression.delivered","campaign.completed","reservation.expired"],"status":"active","description":"AdQuick staging pipeline","created_at":"2026-04-11T14:00:00.000Z","updated_at":"2026-04-11T14:00:00.000Z","last_triggered_at":"2026-04-11T14:05:12.000Z","success_count":42,"failure_count":1,"last_error":null}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/webhooks/deliveries":{"get":{"operationId":"listWebhookDeliveries","summary":"Self-serve webhook delivery log","description":"Returns recent delivery attempts for this DSP seat's webhooks. Each delivery\nrecord captures the event name, target URL, HTTP response code, response\nbody sample, signature, and timestamp. Use this to debug failing deliveries\nwithout having to instrument your own receiver.\n\n**Note:** The delivery log table is being finalized in a follow-up release.\nUntil it ships, this endpoint returns an empty `data` array and a `note`\nfield indicating the stub state. Schema is stable — consumers can integrate\nnow and will begin receiving records when the backing table is live.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["DSP Webhooks"],"parameters":[{"name":"webhook_id","in":"query","required":false,"description":"Filter to a single webhook ID.","schema":{"type":"string"},"example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},{"name":"status","in":"query","required":false,"description":"Delivery status filter.\n","schema":{"type":"string","enum":["failed","success","all"],"default":"all"},"example":"failed"},{"name":"limit","in":"query","required":false,"description":"Max records to return (default 20, maximum 100).","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"example":20},{"name":"offset","in":"query","required":false,"description":"Pagination offset.","schema":{"type":"integer","default":0,"minimum":0},"example":0}],"responses":{"200":{"description":"Delivery log entries. Returns an empty `data` array and a `note` field\nuntil the backing table is finalized in a follow-up release.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"note":{"type":"string","nullable":true,"description":"Present while the delivery log table is being finalized. Will\nbe removed once the backing table ships.\n","example":"Delivery log coming in future PR"},"pagination":{"type":"object","properties":{"limit":{"type":"integer","example":20},"offset":{"type":"integer","example":0},"status":{"type":"string","example":"all"},"webhook_id":{"type":"string","nullable":true,"example":null}}}}},"example":{"success":true,"data":[],"note":"Delivery log coming in future PR","pagination":{"limit":20,"offset":0,"status":"all","webhook_id":null}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/webhooks/{id}":{"delete":{"operationId":"deleteWebhook","summary":"Remove a webhook subscription","description":"Deletes a webhook subscription. Ownership is verified against the\nauthenticated DSP seat — only the seat that created the webhook can delete\nit. Returns 404 if the webhook does not exist.\n\n**Rate limit category:** Campaigns (100 req/min).\n","tags":["DSP Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook ID to delete.","schema":{"type":"string"},"example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"}],"responses":{"200":{"description":"Webhook deleted.","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":100}},"RateLimit-Remaining":{"description":"Requests remaining in the current window","schema":{"type":"integer","example":99}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":58}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"webhook_id":{"type":"string","example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"status":{"type":"string","enum":["deleted"],"example":"deleted"}}}}},"example":{"success":true,"data":{"webhook_id":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC","status":"deleted"}}}}},"400":{"description":"Missing webhook ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"webhook id required"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Webhook not owned by this DSP seat.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Not authorized to delete this webhook"}}}},"404":{"description":"Webhook not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"Database write unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Database write unavailable"}}}}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"x-api-key","description":"DSP API key authentication. Include your API key in the `x-api-key` header:\n```\nx-api-key: tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4\n```\nAPI keys are issued during onboarding (`POST /openrtb/v2/onboard`) and shown\nexactly once. Keys begin with the prefix `tb_dsp_`.\n"},"BearerAuth":{"type":"http","scheme":"bearer","description":"Alternative authentication using the Authorization header:\n```\nAuthorization: Bearer tb_dsp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4\n```\nUses the same API key as `ApiKeyAuth` — choose whichever method fits your\nHTTP client. Both are equivalent.\n"}},"schemas":{"ErrorResponse":{"type":"object","description":"Standard error response format used across all DSP API endpoints.\nAll error responses include `success: false` and a human-readable\n`error` message describing what went wrong.\n","properties":{"success":{"type":"boolean","description":"Always `false` for error responses","example":false},"error":{"type":"string","description":"Human-readable error message. Suitable for logging and debugging.\nDo not parse this string programmatically — use the HTTP status code\nfor control flow.\n","example":"Descriptive error message explaining what went wrong"}}},"DspRegistration":{"type":"object","description":"DSP registration record returned after onboarding","properties":{"id":{"type":"string","format":"uuid","example":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"seat_id":{"type":"string","example":"acme_ads"},"company_name":{"type":"string","example":"Acme Advertising"},"status":{"type":"string","enum":["sandbox","approved","suspended","revoked"],"example":"sandbox"},"openrtb_version":{"type":"string","example":"2.6"},"created_at":{"type":"string","format":"date-time","example":"2026-04-05T14:30:00Z"}}},"ApiKeyInfo":{"type":"object","description":"API key details. The raw key is shown ONCE at creation and never returned again.","properties":{"key":{"type":"string","description":"Full API key — store securely, shown once only","example":"tb_dsp_a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12"},"prefix":{"type":"string","example":"tb_dsp_a1b2c"},"scopes":{"type":"array","items":{"type":"string"},"example":["bid:write","inventory:read","deals:read","stats:read","campaigns:write","reports:read","proof:read"]},"rate_limit_rpm":{"type":"integer","description":"Requests per minute (100 sandbox, 1000 production)","example":100}}},"FEINSignals":{"type":"object","description":"Real-time audience signals from FEIN (Face/Edge Intelligence Network). Updated every 10 seconds from on-device edge AI.","properties":{"live_face_count":{"type":"integer","description":"People currently facing the screen","example":12},"attention_score":{"type":"number","minimum":0,"maximum":1,"description":"Average gaze attention (0=none, 1=full)","example":0.78},"dominant_emotion":{"type":"string","nullable":true,"description":"Most common detected emotion","example":"happy","enum":["happy","sad","angry","surprised","neutral","fear","disgust"]},"income_level":{"type":"string","nullable":true,"enum":["low","medium","high","premium"],"example":"medium"},"dwell_time_ms":{"type":"integer","description":"Average audience dwell time in milliseconds","example":12000},"crowd_density":{"type":"integer","nullable":true,"description":"Estimated venue occupancy","example":45},"purchase_intent":{"type":"string","nullable":true,"description":"Speech-derived purchase intent level","example":"moderate"},"ad_receptivity":{"type":"number","nullable":true,"minimum":0,"maximum":1,"description":"Predicted ad receptivity score","example":0.68},"vas_7d":{"type":"number","nullable":true,"description":"Verified Attention Seconds — 7-day weighted rolling average","example":6.5},"vas_measurements_7d":{"type":"integer","description":"Number of VAS measurements in last 7 days","example":150},"avg_attention_score_7d":{"type":"number","nullable":true,"minimum":0,"maximum":1,"example":0.72},"data_quality":{"type":"string","enum":["live","historical","none"],"description":"live=real-time FEIN, historical=only VAS, none=no audience data","example":"live"},"last_updated":{"type":"string","format":"date-time","nullable":true,"example":"2026-04-05T14:30:00Z"}}},"ContentClassification":{"type":"object","description":"AI-generated content classification from Gemini moderation","properties":{"contains_alcohol":{"type":"boolean","example":false},"contains_tobacco":{"type":"boolean","example":false},"contains_gambling":{"type":"boolean","example":false},"contains_political":{"type":"boolean","example":false},"contains_violence":{"type":"boolean","example":false},"contains_profanity":{"type":"boolean","example":false},"contains_adult_content":{"type":"boolean","example":false},"contains_religious":{"type":"boolean","example":false},"age_rating":{"type":"string","enum":["G","PG","PG-13","R","NC-17"],"example":"G"},"is_known_brand":{"type":"boolean","example":true},"classification_confidence":{"type":"number","minimum":0,"maximum":1,"example":0.95},"classification_reasons":{"type":"array","items":{"type":"string"},"example":["recognized_brand_logo","sports_content"]}}},"ModerationAnalysis":{"type":"object","description":"Detailed AI analysis of creative content","properties":{"subjects":{"type":"array","items":{"type":"string"},"example":["athletic shoes","running"]},"setting":{"type":"string","example":"urban street scene"},"mood":{"type":"string","example":"energetic"},"quality":{"type":"string","example":"professional"},"keywords":{"type":"array","items":{"type":"string"},"example":["Nike","running","sport","fitness"]}}},"Deal":{"type":"object","description":"A programmatic deal (PMP, preferred, or guaranteed)","properties":{"deal_id":{"type":"string","example":"pmp-acme-retail-2026q2"},"deal_type":{"type":"string","enum":["pmp","preferred","programmatic_guaranteed"],"example":"pmp"},"partner_key":{"type":"string","example":"inbound_dsp_acme_ads"},"buyer":{"type":"string","example":"acme_ads"},"floor_cpm":{"type":"number","description":"Minimum CPM in USD","example":2.5},"impression_cap":{"type":"integer","nullable":true,"description":"Total impression cap for the deal","example":500000},"daily_impression_cap":{"type":"integer","nullable":true,"example":50000},"start_date":{"type":"string","format":"date","nullable":true,"example":"2026-04-01"},"end_date":{"type":"string","format":"date","nullable":true,"example":"2026-06-30"},"eligible_venue_types":{"type":"array","items":{"type":"string"},"nullable":true,"example":["retail","restaurant"]},"eligible_countries":{"type":"array","items":{"type":"string"},"nullable":true,"example":["US","CA"]},"status":{"type":"string","enum":["active","pending","archived","expired"],"example":"active"}}},"ScreenPerformance":{"type":"object","description":"Per-screen performance metrics","properties":{"screen_id":{"type":"string","example":"507f1f77bcf86cd799439011"},"impressions":{"type":"integer","example":2500},"avg_cpm":{"type":"number","example":3.2},"fills":{"type":"integer","example":2300},"hours_active":{"type":"integer","example":168}}},"TimeseriesPoint":{"type":"object","description":"A single data point in a time-series report","properties":{"timestamp":{"type":"string","format":"date-time","example":"2026-04-05T10:00:00Z"},"impressions":{"type":"integer","example":750},"avg_cpm":{"type":"number","example":2.45},"active_screens":{"type":"integer","example":38}}},"Pagination":{"type":"object","properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"count":{"type":"integer","example":12}}},"ScreenSummary":{"type":"object","description":"Screen summary returned in campaign creation, discover results, and\nthe default `/adslots` response. The AMP DSP-feed fields\n(`image_url`, `operating_hours`, `slot_duration_seconds`,\n`updated_at`) are surfaced at the top level so partners can hydrate\ntheir own product catalogs without a deep schema walk.\n","properties":{"screen_id":{"type":"string","example":"507f1f77bcf86cd799439011"},"name":{"type":"string","example":"Times Square Kiosk #42"},"image_url":{"type":"string","nullable":true,"description":"Optional CDN URL to a preview/thumbnail image of the screen.\nPartners use this to render a visual representation of the\ninventory in their UI / product catalog. `null` if unset.\n","example":"https://cdn.trillboards.com/screens/507f1f77bcf86cd799439011/preview.jpg"},"operating_hours":{"type":"object","nullable":true,"description":"Optional JSON describing when the screen is online and serving\nads. Shape is partner-extensible — typical structure is\n`{ mon: { open: \"08:00\", close: \"22:00\" }, ... }` in the\nscreen's local timezone.\n","example":{"mon":{"open":"08:00","close":"22:00"},"tue":{"open":"08:00","close":"22:00"},"wed":{"open":"08:00","close":"22:00"},"thu":{"open":"08:00","close":"22:00"},"fri":{"open":"08:00","close":"23:00"},"sat":{"open":"09:00","close":"23:00"},"sun":{"open":"09:00","close":"21:00"}}},"slot_duration_seconds":{"type":"integer","description":"Default per-spot duration in seconds. Defaults to 15s when not\nexplicitly set on the screen. Buyers should treat this as the\ntarget spot length when building creatives.\n","example":15,"default":15},"updated_at":{"type":"string","format":"date-time","nullable":true,"description":"ISO8601 timestamp of the last screen mutation. Use this value\non the next `/adslots?since=<updated_at>` request to fetch\nonly screens that have changed.\n","example":"2026-05-11T00:00:00.000Z"},"venue_type":{"type":"string","nullable":true,"example":"retail"},"country":{"type":"string","example":"US"},"city":{"type":"string","nullable":true,"example":"New York"}}},"PhysicalSize":{"type":"object","description":"Physical display dimensions in inches with a confidence tier indicating\nhow the measurements were captured.\n","properties":{"width_inches":{"type":"number","nullable":true,"example":37.5},"height_inches":{"type":"number","nullable":true,"example":21.1},"diagonal_inches":{"type":"number","nullable":true,"example":43},"confidence":{"type":"string","description":"How the size was determined:\n- `measured` — verified by admin or on-site technician\n- `estimated` — derived from device dimensions/model lookup\n- `default` — fallback value (no device data available)\n","enum":["measured","estimated","default"],"example":"estimated"}}},"SpotConfig":{"type":"object","description":"DOOH-standard spot terminology derived from screen display preferences\nand programmatic allocation. Translates the impression-based model to\ndirect IO buying workflows that count spots.\n","properties":{"display_mode":{"type":"string","enum":["fullscreen","l-bar"],"example":"fullscreen"},"concurrent_ad_slots":{"type":"integer","description":"Number of ad slots visible simultaneously. `1` for fullscreen,\n≥1 for L-bar layouts (depends on position).\n","example":1},"spot_duration_sec":{"type":"integer","description":"Max duration per spot in seconds","example":30},"min_spot_duration_sec":{"type":"integer","description":"Min acceptable spot duration in seconds","example":6},"ad_interval_sec":{"type":"integer","description":"Seconds between consecutive ad plays","example":30},"spots_per_hour":{"type":"integer","description":"Spots delivered per hour (assuming 100% fill)","example":120},"programmatic_share":{"type":"number","minimum":0,"maximum":1,"example":1},"impressions_per_hour":{"type":"integer","example":120}}},"BrandSafety":{"type":"object","description":"Brand-safety rules for a screen — max age rating, blocked IAB\ncategories, and per-category allow flags for sensitive content.\n","properties":{"max_age_rating":{"type":"string","enum":["G","PG","PG-13","R","NC-17"],"example":"PG-13"},"content_filtering":{"type":"boolean","example":true},"blocked_categories":{"type":"array","items":{"type":"string"},"example":[]},"allowed_sensitive":{"type":"object","properties":{"alcohol":{"type":"boolean","example":false},"gambling":{"type":"boolean","example":false},"political":{"type":"boolean","example":false},"cannabis":{"type":"boolean","example":false},"tobacco":{"type":"boolean","example":false},"adult":{"type":"boolean","example":false}}}}},"ScreenProfile":{"type":"object","description":"Full screen profile returned by `GET /openrtb/v2/screens/:id`. Combines\nphysical specs, venue intelligence, live + historical audience data,\n30-day performance rollups, reliability, device capabilities, programmatic\nsettings, and brand-safety flags into a single object.\n","properties":{"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"name":{"type":"string","example":"Downtown Coffee Shop Display"},"physical_size":{"$ref":"#/components/schemas/PhysicalSize"},"spot_config":{"$ref":"#/components/schemas/SpotConfig"},"location":{"type":"object","properties":{"city":{"type":"string","nullable":true},"state":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"zip":{"type":"string","nullable":true},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true},"timezone":{"type":"string","nullable":true}}},"venue":{"type":"object","properties":{"type":{"type":"string","nullable":true},"subcategory":{"type":"string","nullable":true},"grandchild":{"type":"string","nullable":true},"environment":{"type":"string","nullable":true},"placement":{"type":"string","nullable":true},"neighborhood_class":{"type":"string","nullable":true,"example":"dense_urban"},"places_rating":{"type":"number","nullable":true,"example":4.6},"places_review_count":{"type":"integer","nullable":true,"example":1245},"places_price_level":{"type":"integer","nullable":true,"example":2},"quality_score":{"type":"number","nullable":true,"example":0.87},"nearby_amenities":{"type":"object","nullable":true,"properties":{"restaurants":{"type":"integer","example":14},"shops":{"type":"integer","example":22},"bars":{"type":"integer","example":6},"cafes":{"type":"integer","example":9},"transit_stops":{"type":"integer","example":3},"theatres":{"type":"integer","example":1},"gyms":{"type":"integer","example":2},"total":{"type":"integer","example":57}}},"nearby_amenity_count":{"type":"integer","nullable":true,"example":57}}},"audience":{"type":"object","properties":{"vas":{"type":"object","nullable":true,"properties":{"avg_weighted":{"type":"number","example":6.52},"avg_raw":{"type":"number","example":5.48},"median":{"type":"number","example":5.1},"p90":{"type":"number","example":9.8},"tier":{"type":"string","example":"premium"},"cpm_multiplier":{"type":"number","example":1.4},"total_measurements":{"type":"integer","example":4820},"last_measurement":{"type":"string","format":"date-time","nullable":true}}},"live":{"type":"object","nullable":true,"properties":{"face_count":{"type":"integer","example":4},"attention_score":{"type":"number","example":0.78},"dominant_emotion":{"type":"string","nullable":true,"example":"happy"},"crowd_density":{"type":"integer","nullable":true,"example":12},"purchase_intent":{"type":"string","nullable":true,"example":"moderate"},"ad_receptivity":{"type":"number","nullable":true,"example":0.68},"emotional_engagement":{"type":"number","nullable":true,"example":0.72},"screen_engagement":{"type":"number","nullable":true,"example":0.81},"last_updated":{"type":"string","format":"date-time","nullable":true},"data_quality":{"type":"string","enum":["live"],"example":"live"}}},"coverage":{"type":"string","enum":["live","historical","none"],"example":"live"},"estimated_daily_impressions":{"type":"integer","nullable":true,"example":480}}},"display":{"type":"object","properties":{"width_px":{"type":"integer","nullable":true},"height_px":{"type":"integer","nullable":true},"orientation":{"type":"string","nullable":true,"enum":["landscape","portrait","square"]},"sound_enabled":{"type":"boolean"}}},"device":{"type":"object","properties":{"make":{"type":"string","nullable":true},"model":{"type":"string","nullable":true},"os":{"type":"string","nullable":true},"os_version":{"type":"string","nullable":true},"device_type":{"type":"integer","description":"OpenRTB device type (8 = CTV)","example":8},"connection_type":{"type":"string","nullable":true},"video":{"type":"object","properties":{"max_duration_sec":{"type":"integer"},"min_duration_sec":{"type":"integer"},"mimes":{"type":"array","items":{"type":"string"}},"protocols":{"type":"array","items":{"type":"integer"}}}},"capabilities":{"type":"object","properties":{"face_detection":{"type":"boolean"},"audio_classification":{"type":"boolean"},"sound_enabled":{"type":"boolean"}}}}},"performance":{"type":"object","nullable":true,"description":"30-day VAST funnel rollup","properties":{"fill_rate":{"type":"number","nullable":true,"example":0.867},"completion_rate":{"type":"number","nullable":true,"example":0.942},"total_impressions_30d":{"type":"integer","example":14250},"total_completions_30d":{"type":"integer","example":13423},"vast_funnel":{"type":"object","properties":{"requests":{"type":"integer","example":16430},"fills":{"type":"integer","example":14250},"completions":{"type":"integer","example":13423},"errors":{"type":"integer","example":185}}}}},"reliability":{"type":"object","properties":{"online_now":{"type":"boolean"},"last_seen":{"type":"string","format":"date-time","nullable":true}}},"programmatic":{"type":"object","properties":{"floor_cpm":{"type":"number"},"dynamic_floor_pricing":{"type":"boolean"},"auction_enabled":{"type":"boolean"},"auction_type":{"type":"string","example":"first_price"},"allocation_pct":{"type":"integer"},"header_bidding":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string","nullable":true},"timeout_ms":{"type":"integer","nullable":true}}}}},"brand_safety":{"$ref":"#/components/schemas/BrandSafety"}}},"ScreenAvailability":{"type":"object","description":"Per-screen capacity, booked, and remaining availability.","properties":{"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"name":{"type":"string","nullable":true,"example":"Downtown Coffee Shop Display"},"timezone":{"type":"string","example":"America/New_York"},"capacity":{"type":"object","properties":{"total_impressions":{"type":"integer","description":"Deliverable impressions in the window","example":8400},"total_spots":{"type":"integer","example":8400},"impressions_per_hour":{"type":"integer","example":50},"hours":{"type":"number","example":168},"confidence":{"type":"number","minimum":0,"maximum":1,"example":0.92}}},"booked":{"type":"object","properties":{"impressions":{"type":"integer","example":2100},"active_placements":{"type":"integer","example":2},"reserved_impressions":{"type":"integer","example":0}}},"available":{"type":"object","properties":{"impressions":{"type":"integer","example":6300},"spots":{"type":"integer","example":6300},"utilization_pct":{"type":"number","example":25}}},"reliability":{"type":"object","properties":{"online_now":{"type":"boolean"},"last_seen":{"type":"string","format":"date-time","nullable":true}}},"daily_breakdown":{"type":"array","description":"Only present when the request sets `granularity: daily`.\n","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"total_impressions":{"type":"integer"},"booked_impressions":{"type":"integer"},"reserved_impressions":{"type":"integer"},"available_impressions":{"type":"integer"}}}}}},"ScreenAudienceProfile":{"type":"object","description":"Audience profile for a single screen. Combines live Redis signals,\nVAS rolling averages, and venue intelligence. All sections are nullable\nfor graceful degradation — a screen with no live data still returns\n`vas` and `venue` where available.\n","properties":{"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"data_quality":{"type":"string","enum":["live","historical","none"],"example":"live"},"signals_age_ms":{"type":"integer","nullable":true,"description":"Milliseconds since Redis last refreshed live signals. `null`\nwhen there are no live signals.\n","example":8200},"demographics":{"type":"object","nullable":true,"properties":{"avg_face_count":{"type":"number","example":4.2},"avg_attention_score":{"type":"number","minimum":0,"maximum":1,"example":0.76},"avg_dwell_time_sec":{"type":"number","example":12},"crowd_density":{"type":"integer","nullable":true,"example":12},"income_level":{"type":"string","nullable":true,"enum":["low","medium","high","premium"],"example":"medium"},"group_composition":{"type":"string","nullable":true,"description":"Dominant group type (solo, couples, families, friends, work, mixed).","example":"couples"}}},"behavior":{"type":"object","nullable":true,"properties":{"purchase_intent":{"type":"string","nullable":true,"example":"moderate"},"ad_receptivity":{"type":"number","nullable":true,"minimum":0,"maximum":1,"example":0.68},"emotional_engagement":{"type":"number","nullable":true,"minimum":0,"maximum":1,"example":0.72},"dominant_emotion":{"type":"string","nullable":true,"example":"happy"},"screen_engagement":{"type":"number","nullable":true,"example":0.81},"shopping_contexts":{"type":"array","items":{"type":"string"},"example":["coffee","breakfast"]},"brand_mentions":{"type":"array","items":{"type":"string"},"example":["Starbucks"]}}},"venue":{"type":"object","nullable":true,"properties":{"neighborhood_class":{"type":"string","nullable":true,"example":"dense_urban"},"places_rating":{"type":"number","nullable":true,"example":4.6},"foot_traffic_level":{"type":"string","nullable":true,"enum":["low","medium","high","very_high"],"example":"high"},"nearby_amenity_count":{"type":"integer","example":57}}},"vas":{"type":"object","nullable":true,"description":"Verified Attention Seconds scoring","properties":{"score":{"type":"number","example":6.52},"tier":{"type":"string","enum":["unverified","baseline","standard","premium","elite"],"example":"premium"},"cpm_multiplier":{"type":"number","example":1.4}}},"patterns":{"type":"object","nullable":true,"description":"Reserved for hourly audience patterns (ClickHouse-backed). Returns\n`null` until the follow-up release ships.\n"}}},"ProofOfPlayRecord":{"type":"object","description":"A single verified ad play record from `completed_impressions`.\n","properties":{"play_id":{"type":"string","example":"play_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"creative_id":{"type":"string","example":"creative_summer_sale"},"played_at":{"type":"string","format":"date-time","example":"2026-05-01T14:22:31.000Z"},"duration_seconds":{"type":"number","nullable":true,"example":15},"verification_tier":{"type":"string","description":"Maps `ingestion_path` to a DSP-facing tier name:\n- `signed` — Ed25519 signed proof-of-play from the screen\n- `event_confirmed` — VAST `complete` event fired by the IMA SDK\n- `impression_callback` — Impression pixel fired by the screen\n","enum":["signed","event_confirmed","impression_callback"],"example":"signed"},"signature":{"type":"string","nullable":true,"description":"Ed25519 signature when `verification_tier=signed`.","example":"ed25519:AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="},"display_mode":{"type":"string","example":"fullscreen"},"display_area_ratio":{"type":"number","description":"Display area occupied by the ad (1.0 for fullscreen).","example":1},"givt_passed":{"type":"boolean","description":"Whether the play passed GIVT (General Invalid Traffic) checks.","example":true}}},"Reservation":{"type":"object","description":"Screen reservation record. Lifecycle: `held → confirmed | expired | released`.\nReservations auto-expire once `expires_at` passes; `ttl_seconds` is a\nlive countdown that is 0 for non-held statuses.\n","properties":{"reservation_id":{"type":"string","example":"res_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"screen_id":{"type":"string","example":"65a1b2c3d4e5f6a7b8c9d0e1"},"screen_name":{"type":"string","nullable":true,"example":"Downtown Coffee Shop Display"},"creative_id":{"type":"string","nullable":true,"example":"creative_summer_sale"},"campaign_name":{"type":"string","nullable":true,"example":"Summer Sale Launch"},"start_date":{"type":"string","format":"date","example":"2026-05-01"},"end_date":{"type":"string","format":"date","example":"2026-05-14"},"start_time":{"type":"string","example":"09:00"},"end_time":{"type":"string","example":"21:00"},"reserved_impressions":{"type":"integer","example":8400},"floor_cpm":{"type":"number","nullable":true,"example":2.5},"estimated_cost_usd":{"type":"number","nullable":true,"example":21},"status":{"type":"string","enum":["held","confirmed","expired","released"],"example":"held"},"held_at":{"type":"string","format":"date-time","example":"2026-04-11T14:00:00.000Z"},"expires_at":{"type":"string","format":"date-time","example":"2026-04-11T14:15:00.000Z"},"ttl_seconds":{"type":"integer","description":"Live countdown in seconds until `expires_at`. `0` for any status\nother than `held`.\n","example":420},"confirmed_at":{"type":"string","format":"date-time","nullable":true},"released_at":{"type":"string","format":"date-time","nullable":true},"placement_id":{"type":"string","nullable":true,"description":"Populated after confirmation — the placement record that was\ncreated from this reservation.\n"}}},"CpmBenchmark":{"type":"object","description":"CPM percentile benchmark row. One row per venue type (optionally grouped\nby daypart or day depending on the `granularity` query parameter).\n","properties":{"venue_type":{"type":"string","example":"coffee_shop"},"cpm":{"type":"object","properties":{"p25":{"type":"number","example":4.5},"median":{"type":"number","example":7.25},"p75":{"type":"number","example":10.8},"avg":{"type":"number","example":7.42}}},"sample_size":{"type":"integer","example":14520},"period":{"type":"string","example":"30d"},"country":{"type":"string","nullable":true,"example":"US"},"daypart":{"type":"string","nullable":true,"description":"Present when `granularity=daypart`.","example":"evening"},"day":{"type":"string","format":"date","nullable":true,"description":"Present when `granularity=daily`."}}},"DaypartCurve":{"type":"object","description":"One daypart (morning/afternoon/evening/late_night) demand curve row.","properties":{"daypart":{"type":"string","enum":["morning","afternoon","evening","late_night"],"example":"evening"},"avg_cpm":{"type":"number","example":9.28},"fill_rate":{"type":"number","minimum":0,"maximum":1,"example":0.91},"bid_volume":{"type":"integer","example":22040}}},"FunnelStats":{"type":"object","description":"VAST event funnel totals plus computed rates. Maps rollup columns to\nDOOH funnel semantics: `response_count → bids_received`,\n`filled_count → renders_started`, `completed_count → plays_completed`.\n","properties":{"requests":{"type":"integer","example":28450},"bids_received":{"type":"integer","example":26210},"renders_started":{"type":"integer","example":24890},"plays_completed":{"type":"integer","example":22140},"no_bids":{"type":"integer","description":"Approximate no-bid count (`requests - bids - errors`).","example":1820},"errors":{"type":"integer","example":420},"bid_rate":{"type":"number","description":"bids_received / requests","example":0.921},"fill_rate":{"type":"number","description":"renders_started / requests","example":0.875},"start_to_complete":{"type":"number","description":"plays_completed / renders_started","example":0.889}}},"WebhookEvent":{"type":"string","description":"Supported webhook event type. Only event names in this enum are\naccepted by `POST /openrtb/v2/webhooks`.\n","enum":["impression.delivered","impression.verified","campaign.allocated","campaign.started","campaign.completed","creative.moderation.completed","reservation.confirmed","reservation.expired","screen.online","screen.offline"],"example":"impression.delivered"},"WebhookSubscription":{"type":"object","description":"Webhook subscription record as returned by `GET /openrtb/v2/webhooks`.\nThe signing `secret` is never included — it is only shown once at\nregistration time.\n","properties":{"webhook_id":{"type":"string","example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"url":{"type":"string","format":"uri","example":"https://acme-ads.com/webhooks/trillboards"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEvent"}},"status":{"type":"string","enum":["active","paused"],"example":"active"},"description":{"type":"string","nullable":true,"example":"AdQuick staging pipeline"},"created_at":{"type":"string","format":"date-time","example":"2026-04-11T14:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","example":"2026-04-11T14:00:00.000Z"},"last_triggered_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-04-11T14:05:12.000Z"},"success_count":{"type":"integer","example":42},"failure_count":{"type":"integer","example":1},"last_error":{"type":"string","nullable":true,"description":"Most recent error message from a failed delivery. `null` when\nthe webhook has not failed.\n","example":null}}},"WebhookDelivery":{"type":"object","description":"A single webhook delivery attempt. Schema is stable; the delivery log\ntable is finalized in a follow-up release (see `GET /webhooks/deliveries`\n`note` field until then).\n","properties":{"delivery_id":{"type":"string","example":"del_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"webhook_id":{"type":"string","example":"wh_01HS8A3T0X7V4Q9P2MWKJ5N6BC"},"event":{"$ref":"#/components/schemas/WebhookEvent"},"url":{"type":"string","format":"uri","example":"https://acme-ads.com/webhooks/trillboards"},"status":{"type":"string","enum":["success","failed"],"example":"success"},"attempted_at":{"type":"string","format":"date-time","example":"2026-04-11T14:05:12.000Z"},"response_code":{"type":"integer","nullable":true,"example":200},"response_body":{"type":"string","nullable":true,"description":"First 2 KB of the response body, truncated.","example":"{\"ok\":true}"},"duration_ms":{"type":"integer","example":142},"error":{"type":"string","nullable":true,"description":"Error message when `status=failed`.","example":null},"signature":{"type":"string","description":"The `X-Trillboards-Signature` value sent with this delivery.","example":"sha256=abc123..."}}}},"headers":{"RateLimit-Limit":{"description":"Maximum number of requests allowed in the current time window","schema":{"type":"integer","example":1000}},"RateLimit-Remaining":{"description":"Number of requests remaining in the current time window","schema":{"type":"integer","example":997}},"RateLimit-Reset":{"description":"Seconds until the current rate limit window resets","schema":{"type":"integer","example":58}},"Retry-After":{"description":"Seconds to wait before retrying (only present on 429 responses)","schema":{"type":"integer","example":60}}},"responses":{"Unauthorized":{"description":"Missing or invalid API key. Ensure you are including either the `x-api-key`\nheader or `Authorization: Bearer` header with a valid DSP API key.\n\n**Common causes:**\n- Missing authentication header entirely\n- API key has been revoked or rotated\n- Using a Partner API key instead of a DSP API key\n- Typo in the API key value\n","headers":{"WWW-Authenticate":{"description":"Authentication method required","schema":{"type":"string","example":"ApiKey realm=\"Trillboards DSP API\""}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Invalid or missing API key. Include x-api-key header or Authorization: Bearer header."}}}},"Forbidden":{"description":"Insufficient permissions. Your API key is valid but does not have the\nrequired scope for this operation, or your DSP is in a restricted state.\n\n**Common causes:**\n- Sandbox DSP attempting a production-only operation\n- API key missing required scope (e.g., `bid:write` for bid submission)\n- DSP account is suspended\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Insufficient permissions. Your DSP is in sandbox mode. Contact developers@trillboards.com to upgrade."}}}},"NotFound":{"description":"The requested resource was not found. The resource either does not exist\nor does not belong to your DSP.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"success":false,"error":"Resource not found"}}}},"TooManyRequests":{"description":"Rate limit exceeded. Wait for the period indicated by the `Retry-After`\nheader before retrying. Implement exponential backoff for production\nreliability.\n","headers":{"RateLimit-Limit":{"description":"Maximum requests allowed in the current window","schema":{"type":"integer","example":1000}},"RateLimit-Remaining":{"description":"Requests remaining (always 0 when rate limited)","schema":{"type":"integer","example":0}},"RateLimit-Reset":{"description":"Seconds until the rate limit window resets","schema":{"type":"integer","example":42}},"Retry-After":{"description":"Seconds to wait before retrying","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"Rate limit exceeded. Maximum 1000 requests per minute."},"retry_after":{"type":"integer","description":"Seconds to wait before retrying","example":42}}}}}},"InternalServerError":{"description":"Internal server error. An unexpected error occurred on the Trillboards side.\nIf this persists, contact developers@trillboards.com with the timestamp and\nany request details.\n","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","example":"Internal server error. Please try again or contact developers@trillboards.com."},"request_id":{"type":"string","description":"Request ID for support reference. Include this when contacting\ndeveloper support.\n","example":"req_7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c"}}}}}}}}}