# Trillboards > 757,000+ screens reachable through Trillboards and 29,000+ screens registered on our own network. Agencies, brands and their AI agents find, book and measure digital out-of-home campaigns here, with signed proof of play for every delivered ad and audience data measured at the screen. ## What Trillboards sells, and to whom - Advertisers, agencies and buyer agents book screens: with one MCP call (AdCP `create_media_buy`), through programmatic deals over OpenRTB, or in the campaign planner with Stripe checkout. - Measurement buyers get signed proof of play, attribution and attention metrics for the campaigns they run. - Data buyers license audience and movement datasets through the Data API. - Screen owners connect their screens with the Partner API and SDK and earn a revenue share on every paid play. ## Try it with no key Call these on `https://api.trillboards.com/mcp` with no Authorization header: - `get_products`: send a brief in plain language ("coffee drinkers near gyms in Austin, $5,000") and read back real screens, formats and prices, with the hours people are in front of each screen and the dwell and gaze measured there. - `get_pricing`, `get_adcp_capabilities`, `list_creative_formats`: what we charge, what a buy can do, and the creative sizes the screens play. - `list_endpoints`, `describe_endpoint`, `validate_request`, `list_error_codes`, `query_changelog`: the HTTP API, described by the server that runs it. - Network reads such as `get_signals` (the audience signals the network senses) answer with no key while the server lists them as open: `authentication.openTools` in https://api.trillboards.com/mcp/server-card is the current list. A keyed tool's 401 carries `error.data.next_steps`: `register_partner`, REST registration, and the advertiser server for a person who will pay. Plain HTTP answers the same questions: `GET https://api.trillboards.com/api/discover` lists every operation with its credential and agent affordances, and `https://api.trillboards.com/openapi.json` is the OpenAPI index. ## Get a sandbox key in one call Call `register_partner` on `https://api.trillboards.com/mcp` with `company_name` and `email`: it always returns a sandbox key (send no `sandbox` argument; the tool takes none). Or `POST https://api.trillboards.com/v1/partner/agent/register` with `{"agent_type": "dsp_agent", "name": "...", "email": "...", "company": "...", "sandbox": true}`. The key comes back once. Every keyed tool answers with it: media buys validate end to end and return `sandbox: true`, and nothing serves, bills or pays a venue. When you are ready to buy, register at that endpoint with an email the sandbox account does not hold and leave out `"sandbox"`. ## Connect Claude Code: ``` claude mcp add --transport http trillboards https://api.trillboards.com/mcp claude mcp add --transport http trillboards https://api.trillboards.com/mcp --header "Authorization: Bearer $TRILLBOARDS_API_KEY" ``` Cursor (one click, or add the same `url` to `~/.cursor/mcp.json`): ``` cursor://anysphere.cursor-deeplink/mcp/install?name=trillboards&config=eyJ1cmwiOiJodHRwczovL2FwaS50cmlsbGJvYXJkcy5jb20vbWNwIn0= ``` VS Code: ``` code --add-mcp '{"name":"trillboards","type":"http","url":"https://api.trillboards.com/mcp"}' ``` Campaign planner (`https://api.trillboards.com/mcp/advertiser`): `search_ad_inventory` and `preview_campaign_plan` answer with no account; saving, booking and Stripe checkout sign in through OAuth (a client ID metadata document or dynamic client registration). Claude Code: ``` claude mcp add --transport http trillboards-planner https://api.trillboards.com/mcp/advertiser ``` ## Discovery files - MCP server card: https://api.trillboards.com/mcp/server-card (also /.well-known/mcp/server-card.json and /.well-known/mcp.json) - AI catalog: https://api.trillboards.com/.well-known/ai-catalog.json - OpenAPI index: https://api.trillboards.com/openapi.json - API reference: https://api.trillboards.com/docs (guides: https://api.trillboards.com/docs/guides) - Every reference and guide in one file: https://api.trillboards.com/llms-full.txt - AdCP: https://api.trillboards.com/.well-known/adagents.json and https://api.trillboards.com/.well-known/brand.json ## Open data and RL environments - Screen attention by city, venue and hour (CC BY 4.0): camera-measured attention, dwell and gaze in front of Trillboards screens by UTC hour, country, state, city and venue type, every row with its screens and camera windows. 475,282 rows, June 1 to October 11, 2026. Hugging Face: https://huggingface.co/datasets/snehdhruv/screen-attention-by-city-venue-hour · Kaggle: https://www.kaggle.com/datasets/snehdhruv/screen-attention-by-city-venue-hour - The same data as files, one per city, with no key: `GET https://api.trillboards.com/v1/public/attention/TX/houston` returns every hourly row of Houston with all its counts (state and city as the registry names them or as slugs; `?country=` for a city outside the US), and `GET https://api.trillboards.com/v1/public/attention` is the index of every city with its rows and first and last hour. Each weekly vintage carries the same files as `json/attention///.json` on both hubs. - Recorded creative-feedback RL suite, its public evaluation run with every trace: https://hud.ai/jobs/a5c98ef7-7d39-4920-bd65-a0c521ef10f6 - Scored RL environment trials: https://api.trillboards.com/docs/guides/integrations/rl-environments.md --- # Endpoint catalogue # Every operation the four API references at /docs publish, one line each, the # same set GET /api/discover serves (docs/openapi/bundled/*.json). This section # is GENERATED: edit the specs and rebuild, never hand-edit between the # markers. Regenerate with `cd trillboard-api && npm run build:openapi`. # BEGIN-GENERATED endpoint-catalog # AUTO-GENERATED FROM docs/openapi/bundled/*.json — DO NOT EDIT BY HAND. Run: cd trillboard-api && npm run build:openapi # # 432 operations across 4 surfaces. Each line: METHOD path — summary. # Agent affordances (shown where the operation declares them): agent_safe (side-effect-free), idempotent (safe to retry), cost_tier (free | metered | paid). ## Trillboards Data API (data) - GET /v1/data/aggregate — Aggregate audience analytics for a time window. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/lookalike — Find screens with similar audience profiles. (agent_safe=true, idempotent=true, cost_tier=metered) - POST /v1/data/query — Composable query DSL over approved datasets. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/screens — List screens visible to the caller. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/screens/{screenId}/audience — Real-time audience snapshot for a screen. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/screens/{screenId}/history — Historical audience_metrics rows for a screen. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/semantic-search — Similarity search over scene embeddings. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/stats — Platform-wide counters. (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/data/stream — Server-Sent Events stream of live audience/observation events. (agent_safe=true, idempotent=false, cost_tier=metered) - GET /v1/data/usage — API usage for the authenticated key (billing dashboard). (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/example/healthcheck — Example healthcheck (Phase 3 pipeline proof) (agent_safe=true, idempotent=true, cost_tier=free) ## Trillboards Demand API (demand) - GET /openrtb — OpenRTB service root — endpoint catalogue (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/bidder/{connectionAlias}/bid — Bid on partner exchange inventory (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/bidder/{connectionAlias}/imp — Record billable partner exchange impression (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/bidder/{connectionAlias}/loss — Record partner exchange loss notice (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/bidder/{connectionAlias}/spec — Get partner exchange OpenRTB connection spec (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/bidder/{connectionAlias}/win — Record partner exchange win notice (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/health — OpenRTB service liveness probe (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/adpod/{token} — Resolve an ad-pod fulfilment token (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/analytics/events — Get event analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/analytics/impressions — Get impression analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/analytics/interactions — Get interaction analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/analytics/qr-scans — Get QR scan analytics (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/bandwidth-metric — Track bandwidth telemetry (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/bandwidth/analytics — Get aggregated bandwidth analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/bandwidth/screen-usage — Get per-screen bandwidth usage (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/bid — Process OpenRTB bid request (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/command-ack — Acknowledge a remote command issued to a screen (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/complete — Track completion event (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-blocklist — Get creative blocklist (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-history/{screenId} — Get creative history for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-intelligence/experiments — List creative experiments (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/creative-intelligence/experiments — Create creative experiment (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/creative-intelligence/experiments/{id} — Get creative experiment (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /openrtb/v1/creative-intelligence/experiments/{id} — Pause, resume, or conclude creative experiment (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/creative-intelligence/experiments/{id}/analyze — Run statistical analysis for experiment (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-intelligence/insights/{creativeId} — Get creative insights (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-intelligence/performance/{creativeId} — Get creative performance metrics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-intelligence/top — Get top-performing creatives (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-review — List creative reviews (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/creative-review/stats — Get creative review statistics (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/creative-review/{creativeId}/block — Block creative (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/creative-review/{creativeId}/confirm — Confirm or override AI classification (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/creative-review/{creativeId}/unblock — Unblock creative (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/event — Track event via GET (pixel) (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/event — Track event via POST (JSON) (agent_safe=false, idempotent=true, cost_tier=free) - DELETE /openrtb/v1/events/cleanup — Delete old events (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/heartbeat — Track registered-screen heartbeat and targeting identities (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/ima-player/{screenId} — HTML5 IMA SDK player harness for a screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/impression — Record impression (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/impressions/batch — Batch update impressions (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/inline/{token} — Resolve an inline-ad fulfilment token (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/interaction — Track ad interaction (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/interaction-summary — Submit interaction summary (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/omsdk/event — Record OMSDK event (GET image-beacon variant) (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/omsdk/event — Record OMSDK event (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/omsdk/report/{campaignId} — Get OMSDK verification report (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/omsdk/session — Create OMSDK session (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/omsdk/session/{sessionId} — DEPRECATED — returns 410 Gone (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/omsdk/verification.js — Get OMSDK verification script (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/owned-direct/play-token/{screenId} — Mint signed lifecycle URLs for one Schedule direct play (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/performance — Track performance telemetry (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/pixel — Track impression via pixel (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/proof-of-play — Record MRC DOOH proof-of-play (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast — Generate a VAST document for an ad (agent_safe=false, idempotent=false, cost_tier=free) - POST /openrtb/v1/vast-cache-metric — Track VAST cache metric (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast-cache/resolve/{creativeId} — Resolve a cached VAST creative by ID (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v1/vast-request — Track VAST request outcome (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast-request/analytics — Get VAST request analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/vast-test/{screenId} — Generate a test VAST response for a screen (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast-wrap/{provider}/{screenId} — Generate a partner-wrapped VAST response (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast/direct/{screenId}/{adid} — Serve a fixed first-party VAST tag for one booked ad and screen (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v1/vast/{partnerKey}/{screenId} — Generate VAST response on a partner-variant alias (agent_safe=false, idempotent=false, cost_tier=free) - OPTIONS /openrtb/v1/vast/{partnerKey}/{screenId} — CORS preflight for the partner-variant VAST endpoint (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v1/vast/{partnerKey}/{screenId}/debug — Debug view of VAST waterfall for a partner-variant alias (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v1/viewability — Record viewability telemetry (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/win — Handle win notification (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v1/win-notice/{token} — Relay a demand partner's win notice at ad start (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v2/account — Get DSP account profile (commercial terms + deals) (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/adslots — Browse available inventory with FEIN signals (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/analytics/funnel — VAST event funnel (requests → renders → completions) (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/audience/lookalike/{screenId} — Find lookalike screens (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/audience/predict/{screenId} — Predict audience for a screen and time window (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/audience/rank-creatives — Rank creatives for an audience context (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/audience/semantic-search — Semantic search across audience scene descriptions (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/audience/semantic-stats — Get embedding statistics for semantic search (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/bid — Submit OpenRTB 2.6 bid response (agent_safe=false, idempotent=false, cost_tier=paid) - POST /openrtb/v2/campaigns — Create a campaign with targeting (agent_safe=false, idempotent=false, cost_tier=paid) - POST /openrtb/v2/campaigns/assign — Place creative on specific screens (agent_safe=false, idempotent=false, cost_tier=paid) - GET /openrtb/v2/creatives — List your creatives (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/creatives — Upload a creative for AI moderation (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v2/creatives/{id} — Get creative details with full moderation results (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/creatives/{id}/confirm — Confirm presigned upload and trigger moderation (agent_safe=false, idempotent=true, cost_tier=free) - GET /openrtb/v2/deals — List active deals for your DSP (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/deals/propose — Propose a new deal (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v2/deals/{dealId} — Get deal details (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/discover — Semantic inventory search (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/domedia/availability — Quote mapped DOmedia inventory availability (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/audience-segments — Get FEIN audience segments (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/content-audience-correlation — Content category x audience demographic breakdown (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/content-performance/{videoId} — Get content performance by video ID (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/content-recommendations — Get best-performing content for a venue context (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/fein/content-semantic-search — Semantic similarity search over content library (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/creative-venue-performance — Get creative x venue performance matrix (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/fill-rate-by-venue — Get fill rate by venue (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/inference-history — Get FEIN inference history (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/model-status — Get FEIN model status (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/readiness — Get FEIN readiness report (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/screen-intelligence/{screenId} — Get screen intelligence (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/stream — Stream FEIN events over SSE (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/fein/webhooks — List FEIN webhooks (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/fein/webhooks — Register FEIN webhook (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /openrtb/v2/fein/webhooks/{id} — Delete FEIN webhook (agent_safe=false, idempotent=true, cost_tier=free) - POST /openrtb/v2/forecast — Estimate campaign delivery and spend (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/market/cpm-benchmarks — CPM percentile benchmarks by venue and geography (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/market/demand-analysis — Top categories, daypart curves, segment premiums (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/onboard — Register a new DSP (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v2/openapi.json — Machine-readable OpenAPI 3 spec for the OpenRTB v2 surface (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/proof-of-play — Verified ad play records with tier breakdown (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/reports/creatives — Per-creative rollup for the authenticated DSP seat (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/reports/screens — Per-screen performance breakdown (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/reports/summary — Aggregated performance summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/reports/timeseries — Time-series performance data (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/screens/audience — Bulk audience profiles for up to 50 screens (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/screens/availability — Bulk capacity and booking query (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/screens/{id} — Comprehensive screen profile (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/stats — Get 24-hour performance stats (agent_safe=true, idempotent=true, cost_tier=free) - GET /openrtb/v2/webhooks — List webhook subscriptions for this DSP seat (agent_safe=true, idempotent=true, cost_tier=free) - POST /openrtb/v2/webhooks — Register a webhook subscription (agent_safe=false, idempotent=false, cost_tier=free) - GET /openrtb/v2/webhooks/deliveries — Self-serve webhook delivery log (agent_safe=true, idempotent=true, cost_tier=free) - DELETE /openrtb/v2/webhooks/{id} — Remove a webhook subscription (agent_safe=false, idempotent=true, cost_tier=free) ## Trillboards Measurement API (measurement) - GET /v1/advertiser/attribution/campaign/{campaignId} — Campaign attribution summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/attention — Attention metrics summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/attention/creative — Per-creative attention comparison (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/attention/timeseries — Attention metrics over time (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/audience-overindex — Demographic over-index (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/audience-profile — DSP-consensus audience profile (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/causal-lift — Per-segment causal lift (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/channel-roas — Per-channel ROAS (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/attribution/campaign/{campaignId}/cohort/export — Export cohort to DSP (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/advertiser/attribution/campaign/{campaignId}/cohort/refresh — Force cohort re-sync to a DSP (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/creative-attribution — Creative-level attribution performance (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/creative-attribution/{creativeId}/timeseries — Creative attribution over time (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/cross-channel-journey — Cross-channel journey (Sankey flow) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/downtime-experiments — Screen-downtime natural experiments (not measured) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/heatmap — Geospatial exposure heatmap (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/incrementality — Incrementality results (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/journey — Attribution journey funnel (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/methodology — Attribution methodology explanation (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/multi-touch — Multi-touch attribution summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/optimizations — Attribution-driven optimization weights (agent_safe=true, idempotent=true, cost_tier=free) - PUT /v1/advertiser/attribution/campaign/{campaignId}/optimizations/auto — Toggle auto-optimization (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/roas — ROAS breakdown by channel and creative (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/signal-quality — Signal quality and coverage (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/timeseries — Daily attribution timeseries (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/transactions — Transaction attribution & ROAS summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/transactions/timeseries — Daily ROAS timeseries (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/venue-loyalty — Venue loyalty breakdown (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/campaign/{campaignId}/weather-incrementality — Weather-based incrementality (not measured) (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/attribution/experiment — Create incrementality experiment (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/advertiser/attribution/experiment/{experimentId} — Experiment status and results (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/attribution/experiment/{experimentId}/power — Power analysis (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/conversion-source — Connect a measurement source (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/advertiser/conversion-source/{sourceId} — Disconnect a measurement source (soft delete) (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/advertiser/conversion-source/{sourceId} — Fetch one measurement source (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/advertiser/conversion-source/{sourceId} — Update a measurement source (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/advertiser/conversion-source/{sourceId}/capability-url — Get the capability ingest URL for a provider that cannot send a header (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/conversion-source/{sourceId}/deliveries — What this source's provider pushed, and what we answered (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/conversion-source/{sourceId}/roll-secret — Roll a source's signing secret (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/advertiser/conversion-sources — List this advertiser's measurement sources (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/proof/.well-known/public-key — Get public key for signature verification (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/proof/campaigns/{campaignId}/audience-proof — Get audience-enhanced proofs (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/proof/campaigns/{campaignId}/export-proof — Export proofs in batch format (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/proof/campaigns/{campaignId}/proof — Get proofs for a campaign (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/proof/screens/{screenId}/proof — Get proofs for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/advertiser/proof/signature-info — Get signature format documentation (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/advertiser/proof/verify-proof — Verify a proof signature (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/attribution/event/mobile — DSP mobile postback (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/attribution/event/transaction/{sourceId} — Transaction data ingestion (source-scoped, signed) (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/attribution/event/web — Report a web conversion (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/attribution/pixel.js — Web conversion pixel script (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/attribution/pixel/{advertiserId}/{campaignId} — Conversion tracking pixel (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/attribution/postback/adjust — Adjust MMP postback (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/attribution/postback/appsflyer — AppsFlyer MMP postback (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/attribution/postback/kochava — Kochava MMP postback (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/attribution/postback/{provider}/c/{token} — Per-source MMP postback over GET, for providers that default to it (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/attribution/postback/{provider}/c/{token} — Per-source MMP postback authenticated by a capability URL (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/attribution/postback/{provider}/{sourceId} — Per-source signed MMP postback (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/attribution/provider-impression — Provider impression callback (GET) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/attribution/provider-impression — Provider impression callback (POST) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/attribution/qr-signal — Report QR scan device signals (agent_safe=false, idempotent=false, cost_tier=free) ## Trillboards Partner API (partner) - GET /v1/changelog — Query API changelog (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/errors — List all error codes (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/errors/{code} — Look up a single error code (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/federated/gradients — Upload sparsified gradients for multi-slice federated aggregation. (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/federated/models/{modelType} — Fetch aggregated weights for the device-specific slice. (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/federated/models/{modelType}/latest — Fetch the latest aggregated model descriptor (download URL + sha256 + version). (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/federated/performance — Report on-device model performance metrics. (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/federated/taste — Upload a screen-taste embedding for content personalization aggregation. (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/ads/self-promo — List self-promo ads (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/ads/self-promo — Create self-promo ad (URL-based) (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/ads/self-promo/placements — List self-promo placements by screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/ads/self-promo/schedule — Schedule existing self-promo ad to screens (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/ads/self-promo/upload — Create self-promo ad (upload) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/ads/self-promo/{ad_id}/disable — Disable a self-promo ad (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/agent/register — Register an AI agent (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/analytics — Get partner analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/audience-continuity — Visitor-flow continuity graph across the partner's screens (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/audience-profile — Audience composition for the partner's screens (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/ble-summary — BLE device discovery summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/content-performance — Content performance — ad lineage and measured audience reactions (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/creative-catalog — Free-text search across the partner's creative catalogue (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/creative-scenes — Scene-level observations for a played creative (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/cross-screen-flow — Per-pair flow directionality + transition heatmap (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/dashboard — Unified portfolio dashboard (earnings, ads, audience, screens, insights) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/device-suite-signature — Device-type / OS / browser-class distribution among repeat visitors (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/export — CSV export of partner-scope analytics (summary / daily / per-screen) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/export/ads-by-day — Per-ad daily breakdown for the partner fleet (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/fleet-hourly — 24-hour distribution across the partner's fleet (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/halo-funnel — 4-stage discovery → awareness → engagement → conversion funnel (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/loyalty-clock — 24-hour returning-vs-first-timer audience clock (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/network-summary — Fleet-wide audience summary for the partner's screens (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/network-trend — Daily/hourly fleet-wide audience timeseries (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/proximity-summary — Cross-screen proximity-graph summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/revenue-attention-correlation — Per-creative scatter of avg-attention vs revenue-per-impression (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/scene-feed — Paginated chronological feed of scene observations (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/analytics/scene-intelligence — Latest scene description per screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/api-key/rotate — Rotate API key (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/audience-analytics — Get partner-wide audience analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/audience-export — Export audience data (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/audience/heatmap — Get audience heatmap data (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/audience/live/{screenId} — Get live audience data for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/audience/lookalikes/{screenId} — Find lookalike screens (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/partner/audience/predict/{screenId} — Predict screen audience (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/partner/audience/resolved-household-reach/{campaignId} — Resolved household reach for a campaign (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/audience/segments — List audience segments (agent_safe=true, idempotent=true, cost_tier=metered) - POST /v1/partner/audience/segments — Create audience segment (agent_safe=false, idempotent=false, cost_tier=metered) - DELETE /v1/partner/audience/segments/{segmentId} — Delete audience segment (agent_safe=false, idempotent=true, cost_tier=metered) - GET /v1/partner/audience/segments/{segmentId} — Get audience segment (agent_safe=true, idempotent=true, cost_tier=metered) - PATCH /v1/partner/audience/segments/{segmentId} — Update audience segment (agent_safe=false, idempotent=true, cost_tier=metered) - POST /v1/partner/audience/segments/{segmentId}/match — Match screens to segment (agent_safe=false, idempotent=true, cost_tier=metered) - POST /v1/partner/audience/segments/{segmentId}/reach — Estimate segment reach (agent_safe=true, idempotent=true, cost_tier=metered) - POST /v1/partner/audience/segments/{segmentId}/vast-tag — Generate VAST tag for segment (agent_safe=false, idempotent=true, cost_tier=metered) - GET /v1/partner/audience/stats — Get audience data statistics (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/billing/cancel — Cancel the partner's subscription at period end (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/billing/credits — Purchase committed-use credits (agent_safe=false, idempotent=false, cost_tier=paid) - POST /v1/partner/billing/credits/checkout — Create Stripe Checkout session for credits (agent_safe=false, idempotent=false, cost_tier=paid) - GET /v1/partner/billing/plans — List available partner billing plans (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/billing/setup — Set up usage-based billing (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/billing/subscribe — Subscribe the partner to a billing plan (agent_safe=false, idempotent=false, cost_tier=paid) - GET /v1/partner/billing/subscription — Get the partner's current subscription (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/billing/usage — Get the partner's current-period usage (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/billing/usage-summary — Get current billing period usage (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/billing/webhook — Stripe webhook callback (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/connect/dashboard — Get partner dashboard overview (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/connect/earnings — Get earnings chart data (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/connect/health — Get screen health status (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/content-presets — List content presets (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/content-presets/{presetId} — Get content preset (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/creative-experiments — Create an experiment on a funded native media-buy placement (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/creative-experiments/{experiment_id} — Read your native experiment, policy and latest 500 owned creative recovery records (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/creative-experiments/{experiment_id}/brand — Configure your native brand profile and enable its booked reactive campaign (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/creative-experiments/{experiment_id}/creatives/generate — Generate a native brand creative for explicit review and moderation (agent_safe=false, idempotent=true, cost_tier=metered) - POST /v1/partner/creative-experiments/{experiment_id}/creatives/import — Import an AI model’s image through native bytes, moderation and brand review (agent_safe=false, idempotent=true, cost_tier=metered) - POST /v1/partner/creative-experiments/{experiment_id}/creatives/review — Approve or reject your generated creative using native brand review (agent_safe=false, idempotent=false, cost_tier=free) - PUT /v1/partner/creative-experiments/{experiment_id}/policy — Replace native creative weights at the expected policy version (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/creative-experiments/{experiment_id}/rewards — Read a metered page of latest measured native play states (agent_safe=true, idempotent=true, cost_tier=metered) - POST /v1/partner/creative-experiments/{experiment_id}/stop — Stop the native experiment at the expected policy version (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/creative-experiments/{experiment_id}/variants — Register approved native generated creative variants (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/creatives — Submit a creative for classification (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/creatives/{ref} — Get a creative classification (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/dashboard — Get dashboard overview (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/data-export/proximity-graph-edges — Export the cross-screen proximity graph edge list (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/data-export/summary — List available datasets and per-dataset record counts (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/data-export/{dataset} — Export a partner-scoped dataset (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/data/geohash-audience-panel — Geographic audience panel (data-licensing) (agent_safe=true, idempotent=true, cost_tier=metered) - GET /v1/partner/data/outcome-attribution — Outcome-attribution export (data-licensing) (agent_safe=true, idempotent=true, cost_tier=metered) - POST /v1/partner/device — Register a device (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/device/{deviceId} — Delete a device (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/device/{deviceId} — Get device info (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/device/{deviceId} — Update device metadata (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/device/{deviceId}/ads — Get ads for device (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/device/{deviceId}/commands/{commandId}/ack — Acknowledge a device command (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/device/{deviceId}/heartbeat — Read live device heartbeat state (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/device/{deviceId}/heartbeat — Device heartbeat (agent_safe=false, idempotent=true, cost_tier=free) - PATCH /v1/partner/device/{deviceId}/impression-multiplier — Update a device's publisher impression multiplier (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/device/{deviceId}/interactions — Report a rider interaction from the partner SDK (reportInteraction) (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/device/{deviceId}/plays — Report a play from the partner SDK (reportPlay) (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/device/{deviceId}/programmatic-event — Record programmatic ad event (Server-side Analytics) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/device/{deviceId}/trips — Report a taxi-meter state change from the partner SDK (setTripState) (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/devices — List all devices (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/devices — Register a device (V2 single-call onboarding) (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/devices/batch — Bulk register devices (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/devices/batch/orientation — Batch update screen orientation (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/devices/reconcile — Reconcile device fleet (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/docs — Browsable HTML documentation index (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/earnings — Get earnings summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/earnings/breakdown — Get detailed earnings breakdown (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/earnings/payout — Request a payout (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/earnings/transactions — Get transaction history (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/events/connections — List active SSE connections (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/events/stream — Subscribe to real-time events (SSE) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/fleet-commands/{id} — Poll a fleet-command job (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/fleet/analytics — Get fleet analytics (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/fleet/command — Push command to fleet (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/fleet/overview — Get fleet overview (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/fleet/settings — Update fleet-wide settings (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/impression — Record impression (pixel) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/impression — Record impression (POST) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/impressions/batch — Batch record impressions (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/info — Get partner info (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/intent/categories — List intent categories (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/intent/categories/{categoryId} — Get intent category details (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/intent/pricing/{screenId} — Get intent-based pricing for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/measurement-methodology — Trillboards measurement methodology disclosure (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/measurement-methodology.json — Measurement methodology disclosure (JSON, no negotiation) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/network-config — Get network configuration (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/network-config — Partial update network configuration (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/network-config — Update network configuration (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/network/trends — Network-wide audience and inventory trends (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/ping — Unauthenticated liveness probe (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/ping/auth — Authenticated liveness probe (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/plays — Record a log-level play (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/plays/batch — Record a batch of log-level plays (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/pricing — Get machine-readable pricing (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/programmatic-event — Record programmatic ad event (legacy SDK alias) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/quick-start — Quick start — register partner and first device in one call (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/register — Register a new partner (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/sandbox/event — Generate a test event (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/sandbox/reset — Reset sandbox data (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/sandbox/screen — Create a sandbox screen (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/sandbox/status — Get sandbox status (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screen-groups — List screen groups (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screen-groups — Create a screen group (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/screen-groups/{groupId} — Delete (archive) a screen group (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screen-groups/{groupId} — Retrieve a screen group (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screen-groups/{groupId} — Update a screen group (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screen-groups/{groupId}/analytics — Get analytics for a screen group (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screen-groups/{groupId}/commands — Run a fleet command against a screen group (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/screen-groups/{groupId}/content-policy — Get a screen group's content policy (agent_safe=true, idempotent=true, cost_tier=free) - PUT /v1/partner/screen-groups/{groupId}/content-policy — Set a screen group's content policy (agent_safe=false, idempotent=true, cost_tier=free) - DELETE /v1/partner/screen-groups/{groupId}/members — Remove members from a screen group (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screen-groups/{groupId}/members — List a screen group's members (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screen-groups/{groupId}/members — Add members to a screen group (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screen-groups/{groupId}/sensing-config — Get a screen group's sensing config (agent_safe=true, idempotent=true, cost_tier=free) - PUT /v1/partner/screen-groups/{groupId}/sensing-config — Set a screen group's sensing config (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screen/{external_code}/audience-segments — OpenRTB SDA payload for a screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screens/batch/content-preferences — Batch update content preferences (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/screens/batch/display-settings — Batch update display settings (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/screens/batch/programmatic-settings — Batch update programmatic settings (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/resolve — Resolve screen ID by device identifier (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics — Get per-screen analytics (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics/atmosphere — Current atmosphere (mood, noise, crowd density) (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics/intelligence — Time-bucketed venue intelligence with Lincoln-Petersen dedup (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics/taste-profile — Per-screen taste profile + top-5 creative categories (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics/traffic — Hourly foot-traffic patterns for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/analytics/vas — Verified Attention Seconds + CPM tier for a screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screens/{screenId}/apply-preset — Apply content preset to screen (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/audience-metrics — Get real-time audience metrics (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/content-preferences — Get screen content preferences (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/screens/{screenId}/content-preferences — Update content preferences (partial) (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/content-preferences — Update content preferences (full) (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/default-streams — List default streams for a screen (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screens/{screenId}/default-streams — Create a default stream (URL-based) (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/partner/screens/{screenId}/default-streams/images — Upload image carousel default stream (agent_safe=false, idempotent=false, cost_tier=free) - PUT /v1/partner/screens/{screenId}/default-streams/order — Reorder default streams (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/screens/{screenId}/default-streams/upload — Upload a video default stream (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/screens/{screenId}/default-streams/{streamId} — Delete a default stream (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/default-streams/{streamId} — Update a default stream (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/demographics — Get screen demographics (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/display-settings — Get display settings (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/screens/{screenId}/display-settings — Update display settings (partial) (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/display-settings — Update display settings (full) (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/l-bar — Get L-Bar settings (agent_safe=true, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/l-bar — Update L-Bar settings (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/l-bar/live-updates — Update L-Bar live updates (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/programmatic-settings — Get programmatic settings (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/screens/{screenId}/programmatic-settings — Update programmatic settings (partial) (agent_safe=false, idempotent=true, cost_tier=free) - PUT /v1/partner/screens/{screenId}/programmatic-settings — Update programmatic settings (full) (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/resolved-content-policy — Resolve the effective content policy for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/resolved-sensing-config — Resolve the effective sensing config for a screen (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/sensing/chips — Read the custom sensing chips a screen is producing (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screens/{screenId}/sensing/interpret-intent — Generate a custom sensing profile from a natural-language intent (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/screens/{screenId}/sensing/profile — Deploy a custom sensing profile to a screen (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/screens/{screenId}/sensing/settings — Read a screen's AI sensing performance settings (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/screens/{screenId}/sensing/settings — Update a screen's AI sensing performance mode (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/sensing/routes — List your Sense Anything Routes (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/sensing/routes — Create a Sense Anything Route (threshold or scheduled-digest alert) (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/sensing/routes/{routeId} — Delete (soft) a Sense Anything Route (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/sensing/routes/{routeId} — Get one Sense Anything Route (agent_safe=true, idempotent=true, cost_tier=free) - PATCH /v1/partner/sensing/routes/{routeId} — Update a Sense Anything Route (partial, non-clobbering) (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/stripe/connect — Create Stripe Connect account (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/stripe/login-link — Get Stripe dashboard login link (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/stripe/onboarding-link — Get Stripe onboarding link (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/stripe/payout — Request a payout (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/stripe/payouts — Get payout history (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/stripe/status — Get Stripe account status (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/stripe/webhook — Stripe webhook receiver (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/team — List team members (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/team/accept — Accept team invitation (agent_safe=false, idempotent=true, cost_tier=free) - POST /v1/partner/team/invite — Invite a team member (agent_safe=false, idempotent=false, cost_tier=free) - GET /v1/partner/team/invite/{token} — Get invitation details (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/team/{membershipId}/resend — Resend invitation (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/team/{userId} — Remove a team member (agent_safe=false, idempotent=true, cost_tier=free) - PATCH /v1/partner/team/{userId}/role — Change team member role (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/tracking/.well-known/public-key — Get public key for proof verification (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/tracking/batch — Record batch impressions with proof generation (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/usage — Get API usage summary (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/usage/dashboard — Get real-time usage dashboard (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/usage/rate-limit — Get rate limit status (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/vast/config — Get VAST configuration for direct Google integration (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/venues/nearby — List nearby Trillboards venues (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/venues/{screenId}/atmosphere — Real-world atmosphere signals for a venue (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/venues/{screenId}/traffic — Foot-traffic profile for a venue (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/venues/{venueId}/intelligence — Get venue intelligence report (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/partner/webhooks — List webhooks (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/webhooks — Create webhook subscription (agent_safe=false, idempotent=false, cost_tier=free) - DELETE /v1/partner/webhooks/{webhookId} — Delete webhook (agent_safe=false, idempotent=true, cost_tier=free) - PATCH /v1/partner/webhooks/{webhookId} — Update webhook (agent_safe=false, idempotent=true, cost_tier=free) - GET /v1/partner/webhooks/{webhookId}/deliveries — Get delivery history (agent_safe=true, idempotent=true, cost_tier=free) - POST /v1/partner/webhooks/{webhookId}/test — Test webhook (agent_safe=false, idempotent=false, cost_tier=free) - POST /v1/playground/{operation_id} — Dry-run validate a request body against an operation schema (agent_safe=true, idempotent=true, cost_tier=free) - GET /v1/requests/{request_id} — Look up metadata for a recent API request (agent_safe=true, idempotent=true, cost_tier=free) - GET /v2/csi/venue/{screenId}/history — Historical venue occupancy in 10-minute CSI buckets. (agent_safe=true, idempotent=true, cost_tier=free) - GET /v2/csi/venue/{screenId}/occupancy — Real-time venue occupancy from WiFi CSI sensing. (agent_safe=true, idempotent=true, cost_tier=free) - GET /v2/device/ota/manifest — Signed OTA manifest for managed-device update clients. (agent_safe=true, idempotent=true, cost_tier=free) - GET /v2/earner/cameras/sensing-profile — Active sensing profile for the calling connector's bound screen. (agent_safe=true, idempotent=true, cost_tier=free) - POST /v2/sdk/enrich — Census + demographic enrichment from the request IP. (agent_safe=true, idempotent=true, cost_tier=free) - POST /v2/sdk/impression — Ingest an enriched CTV/DOOH impression. (agent_safe=false, idempotent=false, cost_tier=free) - POST /v2/sdk/qr/beacon — Process beacon signals associated with a prior QR scan. (agent_safe=false, idempotent=false, cost_tier=free) - POST /v2/sdk/qr/scan — Record a QR-code scan for physical-to-digital attribution. (agent_safe=false, idempotent=false, cost_tier=free) - POST /v2/sensing/paired-training-samples — Upload a batch of paired camera/CSI training samples. (agent_safe=false, idempotent=true, cost_tier=free) # END-GENERATED endpoint-catalog --- # Trillboards Partner API reference ## Overview Trillboards is a programmatic digital out-of-home (DOOH) advertising platform. Partners integrate our SDK into screens (vending machines, kiosks, digital signage) to display ads and earn competitive revenue share on all impressions. ## API Base URL Production: https://api.trillboards.com/v1/partner Sandbox: same base URL, different ACCOUNT. There is no separate sandbox host — register with {"sandbox": true} (below) and the key you get back is a sandbox account: its media buys validate end to end and return responses marked sandbox:true, but write no advertisements and no placements, so nothing serves, delivers, bills, or pays a venue. Declared as account.sandbox in get_adcp_capabilities. ## Authentication One centralized platform API key authenticates Partner, MCP, reporting, inventory, deals, and OpenRTB control-plane requests. Capabilities decide access; prefixes do not. Self-mint at https://trillboards.com/earner/settings?tab=api (Partner API) and send: ``` Authorization: Bearer ``` For an existing Earner account, the owner/admin can mint the same key programmatically without opening the portal. The login returns a short-lived user JWT; the create response returns `data.rawKey` once: ```bash export EARNER_EMAIL='owner@example.com' export EARNER_PASSWORD='use-your-secret-manager' LOGIN_JSON=$(jq -n \ --arg email "$EARNER_EMAIL" \ --arg password "$EARNER_PASSWORD" \ '{email:$email,password:$password,deviceType:"web"}' | \ curl -sS -X POST https://api.trillboards.com/v1/user/login \ -H 'Content-Type: application/json' --data-binary @-) EARNER_JWT=$(printf '%s' "$LOGIN_JSON" | jq -r '.token') KEY_JSON=$(curl -sS -X POST https://api.trillboards.com/v2/earner/api-keys \ -H "Authorization: Bearer ${EARNER_JWT}" \ -H 'Content-Type: application/json' \ -d '{"name":"Vistars agent integration"}') API_KEY=$(printf '%s' "$KEY_JSON" | jq -r '.data.rawKey') ``` Do not call a registration endpoint for an existing organization: that would create a second account. Store the key in a secrets manager and discard the password/JWT variables after minting. For a brand-new organization only, a headless agent can create the account and same centralized platform key without using the Earner portal: ``` POST https://api.trillboards.com/v1/partner/agent/register Content-Type: application/json { "agent_type": "dsp_agent", "name": "Example Buyer Agent", "email": "programmatic@example.com", "company": "Example Buyer", "sandbox": false } ``` Set `"sandbox": true` for a TEST account (AdCP `account.sandbox`). The `201` response echoes it back as `account.sandbox`, and every media-buy tool response made with that key is marked `sandbox: true`. The `201` response returns `api_key` once plus `mcp_endpoint: https://api.trillboards.com/mcp`. Store the key in a secrets manager. To enable global buyer inventory, reporting, deals, stats, and bidding capabilities, POST /openrtb/v2/onboard with Authorization: Bearer . Onboarding links capabilities to that key; it does not mint another credential. Copy-paste buyer onboarding: ``` API_KEY='' curl -sS -X POST https://api.trillboards.com/openrtb/v2/onboard \ -H "Authorization: Bearer ${API_KEY}" \ -H 'Content-Type: application/json' \ -d '{ "company_name": "Example Buyer", "seat_id": "example_buyer", "contact_email": "programmatic@example.com" }' curl -sS https://api.trillboards.com/mcp \ -H "Authorization: Bearer ${API_KEY}" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` For a white-label agent experience, use an already-provisioned tenant/custom domain that routes the API and configure the agent with `https:///mcp`. That host receives neutral MCP tool descriptions and resources while using the same key and services. OpenRTB seat, sellers.json, schain, callback, and settlement identity stay truthful and are not presentation aliases. Existing tb_prt_*, trb_partner_*, tb_dsp_*, x-api-key, and X-Trillboards-API-Key integrations remain backwards compatible. Unauthenticated endpoints: - GET /device/{id}/ads - GET/POST /impression - POST /impressions/batch - POST /device/{id}/heartbeat ## OpenAPI Specification Interactive docs (Scalar UI): https://api.trillboards.com/docs#partner Public API index: https://api.trillboards.com/docs/openapi.yaml Partner OpenAPI spec: https://api.trillboards.com/docs/openapi/partner-api.yaml Proof-of-Play OpenAPI spec: https://api.trillboards.com/docs/openapi/proof-of-play.yaml Attribution OpenAPI spec: https://api.trillboards.com/docs/openapi/attribution-api.yaml Programmatic & FEIN OpenAPI spec: https://api.trillboards.com/docs/openapi/programmatic-api.yaml OpenRTB Extensions schema spec: https://api.trillboards.com/docs/openapi/openrtb-extensions.yaml Attribution data model spec: https://api.trillboards.com/docs/openapi/attribution-data-models.yaml Partner webhook events spec: https://api.trillboards.com/docs/openapi/partner-webhooks.yaml Webhook signature verification guide: https://api.trillboards.com/docs/integrations/webhook-signature-verification.md Partner integration guide: https://api.trillboards.com/docs/integrations/partner-native-bridge.md ### API Discovery # AI-agent self-navigation endpoints (Phase 6). Unauthenticated, read-only. # Every registered operation is listed with agent_safe / idempotent / cost_tier # flags and example requests, so an agent can enumerate the entire API in a # single call and fuzzy-search across operations + error codes. API discovery index (JSON): https://api.trillboards.com/api/discover API discovery search: https://api.trillboards.com/api/discover/search?q= Agent-facing OpenAPI extensions on each operation: - x-trillboards-agent-safe: true when the endpoint is side-effect-free - x-trillboards-idempotent: true when retries do not double-process - x-trillboards-cost-tier: free | metered | paid - x-trillboards-llm-example: prose "when to call me" hint Canonical error reference (HTML): https://api.trillboards.com/docs/errors Error reference (markdown): https://api.trillboards.com/docs/errors.md OpenAPI index (JSON): https://api.trillboards.com/openapi.json (also https://api.trillboards.com/docs/openapi.json) MCP server (stdio + HTTP): https://api.trillboards.com/mcp Discovery MCP tools: list_endpoints, describe_endpoint, validate_request AdCP agents manifest: https://api.trillboards.com/.well-known/adagents.json # Every canonical error envelope carries `help.try_next` pointing at # /api/discover when the caller supplies an ID that isn't in their scope. # Follow that URL to get ranked suggestions and recover without reading docs. ## Key Concepts ### Partner - Company or developer integrating our ad platform - Receives API key on registration - Manages multiple devices - Earns competitive revenue share on impressions ### Device - Physical screen displaying ads - Identified by fingerprint (e.g., "P_abc123") or external device ID - Can be vending_machine, kiosk, tablet, display, or other - Reports heartbeat for online status ### Impression - Single ad view on a device - Tracked automatically by SDK or via API - Supports batch sync for offline scenarios - Idempotent on `impid + adid` (duplicate submissions are ignored) - Optional signature for verification: send `X-Trillboards-API-Key`, `X-Trillboards-Timestamp`, `X-Trillboards-Signature` where payload is `{ts}.{adid}.{impid}.{did}.{sid}.{aid}` - Optional display context: `display_mode`, `display_area_ratio`, `lbar_size`, `lbar_position`, `lbar_slot_index`, `lbar_slot_count` - Batch impressions can include per-impression `sig` and `sig_ts` fields using the same payload format ## Quick Start Flow 1. Register partner (POST /register) - Returns API key (store securely!) - Partners are auto-activated for instant onboarding - If the partner owner has no password yet, the response includes portal_access with claim endpoints 2. Claim portal account (if required) - POST /claim/request with { email, partner_id } to request a one-time claim link - POST /claim/verify with { token } to verify claim token - POST /claim/complete with { token, password } to set a password and activate access - Email verification can run in parallel; do not block claim flow on verification 2. Register device (POST /device) - Provide device_id, name, location - Returns fingerprint, screen_id, and embed_url 3. Display ads - Load embed_url in WebView/iframe - SDK handles ad rotation automatically - Or fetch ads via GET /device/{id}/ads 4. Track impressions - SDK does this automatically - Or POST /impression for custom tracking - Batch offline impressions via POST /impressions/batch 6. Check analytics (GET /analytics) - View impressions, revenue, device stats ## Endpoints Summary ### Partner Management - POST /register - Register new partner, get API key - GET /info - Get partner details and stats - POST /claim/request - Request account-claim token for portal access - POST /claim/verify - Verify account-claim token - POST /claim/complete - Set password and finalize account claim ### Device Management - POST /device - Register/update a device - GET /devices - List all devices (paginated) - POST /devices/batch - Bulk register up to 100 devices - POST /devices/reconcile - Read-only fleet diff (matched/missing/orphan/malformed/duplicate) vs your external_ids - GET /device/{id} - Get device by ID/fingerprint - DELETE /device/{id} - Soft-delete device ### CMS (Screen Content) - GET /screens/resolve?device_id=... - Resolve screen_id from device identifiers - GET /screens/{screenId}/default-streams - List background streams - POST /screens/{screenId}/default-streams - Create background stream (URL-based) - POST /screens/{screenId}/default-streams/upload - Upload video stream (multipart) - POST /screens/{screenId}/default-streams/images - Upload image carousel (multipart) - PUT /screens/{screenId}/default-streams/{streamId} - Update stream - DELETE /screens/{screenId}/default-streams/{streamId} - Delete stream - PUT /screens/{screenId}/default-streams/order - Reorder streams - GET /screens/{screenId}/l-bar - Get L-bar settings/content - PUT /screens/{screenId}/l-bar - Update L-bar settings/content - PUT /screens/{screenId}/l-bar/live-updates - Update L-bar live updates - GET /ads/self-promo - List self-promo ads for full embed - POST /ads/self-promo - Create self-promo ad (URL-based) - POST /ads/self-promo/upload - Upload self-promo ad media - POST /ads/self-promo/schedule - Schedule self-promo ad to screens - POST /ads/self-promo/{adId}/disable - Disable self-promo placements - GET /ads/self-promo/placements - List self-promo placements by screen ### Ad Serving (No Auth Required) - GET /device/{id}/ads - Get ads for SDK (supports ETag caching, id = fingerprint or external device ID) - Optional player-truth query params: slot_w, slot_h, orientation, muted, autoplay - Returns header_bidding_settings with a VAST waterfall (Adipolo, VidVerto, IncrementX, GAM, etc.) - Each source has: name, vast_url (points to our vast-wrap endpoint), timeout_ms, priority, cpm, request_id ### Native IMA SDK Integration (Definitive Guide) Full guide: https://api.trillboards.com/docs/integrations/partner-native-ima-sdk.md Production-verified path for native Android IMA SDK players (Rockchip digital signage, Media3 + ImaAdsLoader). Take 10 Media reference integration: 61% Adipolo fill rate, 79% completion rate, ~48% effective. **Five-endpoint flow:** 1. `POST /v1/partner/device/{deviceId}/heartbeat` every 60s — send full device identity 2. `GET /v1/partner/device/{deviceId}/ads` — get waterfall with VAST sources 3. `GET {source.vast_url}` (our vast-wrap endpoint) — fetch resolved VAST XML 4. Pass full XML unmodified to IMA SDK via `setAdsResponse` or Media3 `data:text/xml` URI 5. `POST /v1/partner/device/{deviceId}/programmatic-event` for ad_started / ad_ended / no_fill / error **Critical requirements (every one of these was a discovered-the-hard-way bug):** - Heartbeat `device.ua` must be the real WebView UA with Build fingerprint, unique per device — read via `WebView(context).settings.userAgentString` or `WebSettings.getDefaultUserAgent(context)`. Same UA across your fleet = DSP IVT flags you as bot traffic. - Heartbeat `device.make` = `Rockchip` (title case). Lowercase = unknown vendor to DSPs. - Heartbeat `device.model` = real chipset from `Build.BOARD` (e.g. `rk3568_r`), NOT your internal SKU. - Heartbeat `device.osv` = API level as string (`30` for Android 11, `34` for Android 14), NOT version name (`9`). - **Do NOT set `ImaAdsLoader.setAdMediaMimeTypes("video/mp4")`** — this blocks VPAID and non-mp4 creatives. Leave unset. This was the single biggest blocker for Take 10. - **Do NOT parse, strip, or modify the VAST XML before handing it to IMA SDK.** Our tracking pixels are embedded at the wrapper level. Stripping wrappers = no impression attribution = no revenue. Pass the full response body literally. - `programmatic-event` requests must include both `request_id` and `variant_name` — events without these are dropped. - You only need to report `programmatic-event` for the first source that actually played, not for sources skipped by the waterfall. **What the server does for you:** - `vast-wrap` fetches upstream VAST with your device's IP via `X-Forwarded-For` (correct geo targeting) - Resolves wrapper chains server-side up to 5 hops (Adipolo wrapper → GAM → inline creative) so your device gets playable VAST without chain-following - Injects tracking pixels with `&provider={source_name}` baked in at injection time — no post-hoc attribution needed - Automatic impression counting via pixel fires — on top of `programmatic-event`, deduplicated by `request_id + impression_id` ### Impression Tracking - GET /impression?adid=x&impid=y - Record single impression - POST /impression - Record single impression - POST /impressions/batch - Record up to 100 impressions ### VAST Integration (Direct Google Integration) For enterprise partners who want to fetch ads directly from Google Ad Manager: - GET /vast/config - Get VAST tag URL template (cache for 1 hour) - Returns template URL, targeting params, timeouts, and tracking endpoints - Partner builds VAST URL, fetches from Google directly - Trillboards NOT in critical ad path ### Batch Tracking (High-Volume Impressions) For partners with 10,000+ screens: - POST /tracking/batch - Record up to 100 impressions with Ed25519 proofs - GET /tracking/.well-known/public-key - Get public key for proof verification (no auth) ### Device Health - POST /device/{id}/heartbeat - Report device online status (id = fingerprint or external device ID) ### Analytics - GET /analytics - Partner-level analytics with date filtering ### Creative Moderation - POST /creatives - Submit a public/CDN creative URL; returns structured classification (moderation, age rating, IAB categories, subjects, keywords, OCR, brand). Async: 202 + `id` of form `sha256:`; poll until terminal. Returns 200 immediately if the same bytes were already classified. - GET /creatives/{ref} - Read the classification for a creative you submitted (ref = `sha256:` or bare hex). Entitlement-gated to content you submitted. Use `id`/`content_sha256` as `creative_ref` when posting log-level plays so plays join to this classification. ### Measurement Plays - POST /plays - Record a log-level play for an ad you serve yourself. Body: {screen_id, creative_ref (=content_sha256 from /creatives), play_id, completed_at, duration_seconds?, ad_id?}. Non-billing; lands in partner_measurement_plays and joins to classification + audience moments. Idempotent on play_id; screen_id must be one of your registered screens. 202 {status: accepted|duplicate}. - POST /plays/batch - Up to 500 plays in one request (same shape). Per-play validation; response reports rejected plays. ### Dashboard - GET /dashboard - Get partner dashboard overview with stats, earnings, top screens - GET /screens/{screenId}/analytics - Get screen-level analytics (period: 7d, 30d, 90d) ### Team Management Team management endpoints require dual authentication: - Centralized platform API key in Authorization header: `Bearer ` - User JWT in X-User-Token header for write operations **Read Endpoints (API key only):** - GET /team - List all team members with roles and permissions - GET /team/invite/{token} - Get invitation details (public, for invite acceptance) **Write Endpoints (API key + User JWT + Owner role):** - POST /team/invite - Invite a team member (body: { email, role, name? }) - POST /team/{membershipId}/resend - Resend invitation email - PATCH /team/{userId}/role - Change member role (body: { role }) - DELETE /team/{userId} - Remove team member Team Roles: - **owner**: Full access (view all, manage team, request payouts, rotate API keys) - **admin**: View screens/analytics, edit screens, configure webhooks - **viewer**: View screens only ### Earnings - GET /earnings - Get earnings summary (lifetime, this month, available for payout) - GET /earnings/transactions - Get transaction history (paginated) - GET /earnings/breakdown - Get earnings breakdown by day/week/screen - POST /earnings/payout - Request payout (requires Stripe Connect) ### Quick Start - POST /quick-start - Register partner + first device in one call (no auth, rate limited) - Returns API key, device credentials, SDK snippets, and configuration - Single-call alternative to POST /register + POST /device ### API Key Management - POST /api-key/rotate - Generate new API key with 24h grace period for old key ### Stripe Connect (Partner Payouts) - POST /stripe/connect - Create Stripe Connect Express account - GET /stripe/onboarding-link - Get Stripe onboarding URL for identity/bank setup - GET /stripe/login-link - Get temporary Stripe Express dashboard link - GET /stripe/status - Get account status, payout readiness, and requirements - POST /stripe/payout - Request payout (min $5.00, requires active Stripe account) - GET /stripe/payouts - Get payout history (paginated) ### Connect Dashboard (SDK Partner Dashboard) Pre-aggregated data for embedding in partner dashboards: - GET /connect/dashboard - Full dashboard: screens, impressions, earnings, charts, top screens - GET /connect/earnings - Daily/weekly earnings chart data - GET /connect/health - Screen health status (online/offline/never_connected) ### Fleet Management For partners with large screen networks: - GET /fleet/overview - Fleet device counts, venue types, country breakdown - GET /fleet/analytics - 24h fleet-wide impressions, fill rate, CPM, attention - POST /fleet/command - Push command to fleet (reload, restart, screenshot, etc.) - Commands: reload, update_settings, restart, screenshot, clear_cache, update_content, refresh_ads, update_config, get_state, show_ad, play_ad - Supports filter by screenIds, venueType, country (max 500 devices/batch) - POST /fleet/settings - Update fleet-wide settings (header_bidding, content_preferences, display) ### Venue Intelligence Real-world venue data from CTV audience sensing: - GET /venues/{venueId}/intelligence - Aggregated venue report with timeseries + dedup - Query params: from, to (ISO dates, max 30 days), interval (5min/15min/1h/1d) - GET /venues/{screenId}/traffic - Hourly foot traffic patterns + busy score (0-10) - GET /venues/{screenId}/atmosphere - Current mood, noise, crowd density, music detection - GET /venues/nearby - Find nearby venues with live traffic (query: lat, lng, radiusKm) - GET /network/trends - Network-wide audience trends (period: 24h/7d/30d) ### SSE Events (Real-Time Streaming) Server-Sent Events for live event streaming: - GET /events/stream - Open SSE connection (max 5 per partner) - Query param: events (comma-separated filter, empty = all) - Supports Last-Event-ID for reconnection + missed event replay - 30s heartbeat to keep connection alive - GET /events/connections - List active SSE connections ### API Usage & Rate Limits - GET /usage - API usage summary (query: start_date, end_date, default 30d, max 90d) - GET /usage/dashboard - Real-time usage dashboard (today + 7-day history) - GET /usage/rate-limit - Current rate limit status per tier (basic/developer/enterprise) ### Network Configuration (White-Label) - GET /network-config - Get network config + parent partner ID - PUT /network-config - Full update (default_content_preferences, default_waterfall, branding) - PATCH /network-config - Partial update of specific nested fields ### Device Commands - POST /device/{deviceId}/commands/{commandId}/ack - Acknowledge command execution ### Sandbox (Beta — Integration Testing) Testing tools with synthetic data (24h TTL): - POST /sandbox/screen - Create sandbox screen (max 10 per partner) - POST /sandbox/event - Generate test event (17 event types supported) - Events also delivered via SSE if connection is active - POST /sandbox/reset - Clear all sandbox data - GET /sandbox/status - Get sandbox screens, events, recent activity ### Audience Intelligence (Beta) Advanced audience analytics: - GET /audience/live/{screenId} - Real-time audience data (all tiers) - GET /audience/heatmap - Geospatial audience heatmap (all tiers) - GET /audience/stats - Data availability statistics (all tiers) - GET /audience/lookalikes/{screenId} - Find similar audience screens (Developer+ tier) - GET /audience/predict/{screenId} - Predict audience by hour/day (Developer+ tier) ### Audience Segments (Beta — Enterprise Tier) Custom audience targeting: - POST /audience/segments - Create segment with rules - GET /audience/segments - List all segments - GET /audience/segments/{segmentId} - Get segment details - PATCH /audience/segments/{segmentId} - Update segment - DELETE /audience/segments/{segmentId} - Delete segment - POST /audience/segments/{segmentId}/match - Find matching screens - POST /audience/segments/{segmentId}/reach - Estimate reach + revenue - POST /audience/segments/{segmentId}/vast-tag - Generate targeted VAST tag ### Data API (Third-Party Access) Authenticated REST API for data partners (Place Exchange, OpenAI, Anthropic, etc.) to access audience intelligence, screen data, and semantic search. Base URL: https://api.trillboards.com/v1/data Authentication: X-API-Key header (not Bearer token) Scopes: - read:live — Real-time screen and audience data - read:historical — Historical audience metrics and aggregates - read:semantic — Scene embedding semantic search Endpoints: - GET /screens - List active screens (query: limit, offset, venue_type, country) - GET /screens/{screenId}/audience - Real-time audience snapshot from Redis - GET /screens/{screenId}/history - Historical audience_metrics (query: since, until, limit) - GET /semantic-search - Scene embedding similarity search (query: q, limit, since, min_similarity) - GET /lookalike - Find screens with similar audience profiles (query: screen_id, limit, min_similarity) - GET /aggregate - Aggregate analytics by group_by (venue_type|daypart|day_of_week|income_estimated_level|primary_mood) and metric (avg_face_count|avg_attention|income_confidence|max_face_count) - GET /stats - Data API stats (embedding counts, active screens, metrics volume) ### Intent Catalog (Beta — Intent-Based Pricing) Purchase intent detection and pricing: - GET /intent/categories - List intent categories with CPM multipliers - GET /intent/categories/{categoryId} - Get category details + triggers - GET /intent/pricing/{screenId} - Real-time intent pricing for a screen ## Data Access Tiers | Tier | Rate Limit | Access | |------|-----------|--------| | Basic | 200/min | Own screens, standard analytics | | Developer | 1,000/min | + Venue intelligence, lookalikes, predictions | | Enterprise | 5,000/min | + Audience segments, raw exports, SLA | ## Rate Limits | Endpoint Type | Limit | Window | |--------------|-------|--------| | Registration | 5 | 1 hour | | Authenticated API | 1000 | 1 minute | | Ad serving | 100 | 1 minute | | Impressions | 500 | 1 minute | | Batch impressions | 100 | 1 minute | | Heartbeat | 60 | 1 minute | | Venue intelligence | 100 | 1 minute | | Audience intelligence | 100 | 1 minute | | Data API | 60 | 1 minute | | Bulk device operations | 20 | 1 minute | Rate limit headers returned on all responses: - X-RateLimit-Limit - X-RateLimit-Remaining - X-RateLimit-Reset ## Error Responses All errors follow a consistent format with helpful guidance: ```json { "success": false, "error": { "type": "invalid_request_error", "code": "authentication_required", "message": "Authentication required. Provide your Partner API key in the Authorization header.", "param": "Authorization", "doc_url": "https://api.trillboards.com/docs/integrations/partner-native-bridge.md#errors", "help": { "summary": "Send your Partner API key in the Authorization header.", "example": "Authorization: Bearer trb_partner_xxxxx", "docs": "https://api.trillboards.com/docs/integrations/partner-native-bridge.md", "portal_url": "https://trillboards.com/earner" } }, "request_id": "req_123" } ``` HTTP Status Codes: - 400: Bad request (validation error) - 401: Unauthorized (invalid/missing API key) - 404: Resource not found - 429: Rate limit exceeded - 500: Server error ## Example: Register Device Request: ```bash curl -X POST https://api.trillboards.com/v1/partner/device \ -H "Authorization: Bearer trb_partner_xxxxx" \ -H "Content-Type: application/json" \ -d '{ "device_id": "vending-001-nyc", "name": "NYC Office Lobby Vending", "device_type": "vending_machine", "location": { "lat": 40.7128, "lng": -74.0060, "venue_type": "office", "city": "New York", "state": "NY", "country": "US" } }' ``` Response: ```json { "success": true, "message": "Device registered", "data": { "device_id": "6507a1b2c3d4e5f6g7h8i9j0", "external_device_id": "vending-001-nyc", "fingerprint": "P_a1b2c3d4e5f6g7h8", "screen_id": "6507a1b2c3d4e5f6g7h8aa11", "status": "active", "embed_url": "https://screen.trillboards.com?fp=P_a1b2c3d4e5f6g7h8", "enrichment_status": "pending" } } ``` ## Example: Get Partner Dashboard Request: ```bash curl https://api.trillboards.com/v1/partner/dashboard \ -H "Authorization: Bearer trb_partner_xxxxx" ``` Response: ```json { "success": true, "data": { "partner": { "name": "Acme Vending Co", "slug": "acmevending", "status": "active" }, "stats": { "total_screens": 150, "active_screens": 142, "offline_screens": 8, "total_impressions_today": 45000, "total_impressions_week": 315000, "total_impressions_month": 1350000 }, "earnings": { "revenue_share_percent": 70, "total_earned_cents": 4500000, "available_payout_cents": 125000, "pending_payout_cents": 0 }, "top_screens": [...], "recent_activity": [...], "stripe_status": { "account_status": "active" } } } ``` ## Example: Invite Team Member Request: ```bash curl -X POST https://api.trillboards.com/v1/partner/team/invite \ -H "Authorization: Bearer trb_partner_xxxxx" \ -H "X-User-Token: " \ -H "Content-Type: application/json" \ -d '{ "email": "teammate@company.com", "role": "admin", "name": "Jane Doe" }' ``` Response: ```json { "success": true, "message": "Invitation sent successfully", "data": { "membership_id": "6507a1b2c3d4e5f6g7h8i9j0", "email": "teammate@company.com", "role": "admin", "status": "pending", "expires_at": "2026-02-02T12:00:00.000Z" } } ``` ## Example: Data API Semantic Search Request: ```bash curl "https://api.trillboards.com/v1/data/semantic-search?q=sports%20bar%20crowd&limit=10&since=24h" \ -H "X-API-Key: trb_data_xxxxx" ``` Response: ```json { "query": "sports bar crowd", "results_count": 3, "time_window": "24h", "results": [ { "screen_id": "6507a1b2c3d4e5f6g7h8i9j0", "scene_description": "Lively sports bar with crowd watching game on multiple screens, high energy", "similarity": 0.89, "window_end": "2026-02-28T02:30:00.000Z" } ] } ``` ## Example: Data API Aggregate Analytics Request: ```bash curl "https://api.trillboards.com/v1/data/aggregate?group_by=daypart&metric=avg_attention&since=7d" \ -H "X-API-Key: trb_data_xxxxx" ``` Response: ```json { "group_by": "daypart", "metric": "avg_attention", "time_window": "7d", "aggregations": [ { "group_key": "evening", "sample_count": 1250, "avg_value": 0.72, "min_value": 0.15, "max_value": 0.98 }, { "group_key": "afternoon", "sample_count": 980, "avg_value": 0.65, "min_value": 0.10, "max_value": 0.95 } ] } ``` ## SDK Integration For web-based displays, load the embed_url in an iframe or WebView: ```html ``` The SDK automatically: - Fetches and rotates ads - Caches ads for offline playback - Tracks impressions - Reports heartbeat status For programmatic-only usage (default for the Lite SDK), trigger ads via: ``` window.TrillboardsLite.show() ``` Direct ad fallback is opt-in via the Lite SDK `waterfall` options (`programmatic_then_direct` or `direct_only`). --- ## CTV Measurement SDK Audience-measurement SDK for Connected TV. Two runtimes, one wire format. The canonical quickstart is rendered at and the raw markdown lives at . ### Coordinates - npm: `@trillboards/ctv-measurement` (CTV web, smart-TV web apps, ATSC 3.0 Broadcaster Applications, HbbTV receivers, React Native) - Gradle / Maven: `com.trillboards:ctv-measurement:1.3.2` (Android TV / Fire TV / native tablet apps; built on the same `agent-core-lite` proximity stack the Trillboards in-house fleet runs in production today) ### Get an API key (self-serve) Web form: API: `POST https://api.trillboards.com/v1/partner/register` with body `{ "name": "...", "slug": "...", "contact_email": "...", "api_agreement_accepted": true }`. The response contains a one-time `api_key` of the form `trb_partner_xxxxx` (also issued as `tb_prt_xxxxx`) — your Trillboards Partner API key. It carries the `sdk:*` scope by default, so the same key authenticates ad-serving AND this measurement SDK — no separate CTV key, no email round-trip, no sales call. Manage / rotate / revoke the key in the earner portal at under **Partner API**. ### Endpoints The SDKs call these endpoints automatically. All accept the same Trillboards Partner API key (`trb_partner_*` / `tb_prt_*`) via the `Authorization: Bearer ` header. | Method | Path | Purpose | |--------|----------------------------------------------|------------------------------------------------------| | POST | `/v1/partner/register` | Self-serve Partner API key minting (one-time) | | POST | `/v1/partner/device/{deviceId}/heartbeat` | Audience-measurement heartbeat (every 30 s) | | POST | `/v1/partner/api-key/rotate` | Rotate your Partner API key without downtime | | GET | `/v1/partner/info` | Read the partner record (slug, scoped regions, etc.) | ### npm install (CTV web / smart-TV / ATSC 3.0 / HbbTV / React Native) ```bash npm install @trillboards/ctv-measurement ``` ```ts import { CtvMeasurement } from '@trillboards/ctv-measurement'; const sdk = new CtvMeasurement({ apiKey: 'trb_partner_YOUR_KEY', deviceId: 'your-device-id', }); sdk.setConsentStatus(true); // consent gate — measurement is inert until this await sdk.measure(); // composes payload, POSTs heartbeat, returns result ``` ### Gradle / Maven install (Android native — Android TV / Fire TV / tablet) `settings.gradle.kts`: ```kotlin dependencyResolutionManagement { repositories { maven { url = uri("https://maven.trillboards.com") } } } ``` `app/build.gradle.kts`: ```kotlin dependencies { implementation("com.trillboards:ctv-measurement:1.3.2") } ``` `MainActivity.kt` (or `Application.onCreate`): ```kotlin import com.trillboards.measurement.TrillboardsMeasurement TrillboardsMeasurement.initialize( context = this, apiKey = "trb_partner_YOUR_KEY", deviceId = "your-device-id", ) TrillboardsMeasurement.setConsentStatus(true) TrillboardsMeasurement.startScheduledScans() ``` `startScheduledScans()` runs the BLE / WiFi / mDNS / SSDP / HTTP-probe scanners on a 30-second cadence and POSTs each snapshot as a heartbeat. ### Consent gate (GDPR / CCPA) Both SDKs are inert until `setConsentStatus(true)` is called. No network traffic, no scanning, no fingerprinting. Revoke with `setConsentStatus(false)`; in-flight requests complete but no new measurements are emitted. ### Platform target matrix | Runtime | Universal core | Broadcast metadata | Path | |------------------------------------------|----------------|------------------------------|-------------| | ATSC 3.0 Broadcaster Application | yes | yes (A/344 `localhost:` WS) | npm SDK | | HbbTV 2.0+ receiver | yes | yes (`oipfObjectFactory`) | npm SDK | | Samsung Tizen TV web app | yes | no (privileged only) | npm SDK | | LG webOS TV web app | yes | no (privileged only) | npm SDK | | Vizio SmartCast web app | yes | no | npm SDK | | Fire TV / Google TV / Android TV WebView | yes | no (bridge to native) | npm SDK | | Android TV / Fire TV native app | yes | n/a | Android AAR | | Android tablet / phone | yes | n/a | Android AAR | | Roku BrightScript / SceneGraph | n/a | n/a | unsupported | | Apple tvOS (TVMLKit / JSC) | partial | n/a | unsupported | ### Verification SQL After the first heartbeat lands, confirm rows are arriving for your device: ```sql SELECT source, count() AS rows FROM trillboards.signal_observations WHERE screen_mongo_id = 'your-device-id' AND observed_at > now() - INTERVAL 5 MINUTE GROUP BY source ORDER BY rows DESC; ``` You should see rows for `fingerprint` (every runtime) plus any of `ble` / `wifi` / `mdns` / `ssdp` / `http_probe` / `broadcast` your platform supports. ### Further reading - Quickstart (web): - Partner pitch: - Self-serve key form: - Quickstart (markdown): - Native-bridge protocol: - Partner integration cookbook: - Partner API OpenAPI: --- # Trillboards DSP API # Programmatic buying for DOOH inventory ## Overview The DSP API lets demand-side platforms, agencies, and advertisers programmatically discover, buy, and serve ads on Trillboards screens. ## Base URL https://api.trillboards.com/openrtb/v2 ## Authentication Use the same self-minted platform key as MCP/reporting: Authorization: Bearer POST /openrtb/v2/onboard with that header links the same key to a DSP seat and grants DSP capabilities. Prefixes do not authorize. Anonymous onboarding still mints tb_dsp_* only for backwards compatibility. ## OpenAPI Specification YAML: https://api.trillboards.com/docs/openapi/dsp-api.yaml JSON (machine-readable, canonical for agents): https://api.trillboards.com/openrtb/v2/openapi.json Discovery: https://api.trillboards.com/api/discover?surface=demand ## Quick Start 1. Mint one key in Earner Settings → Partner API, or headlessly via POST /v1/partner/agent/register 2. POST /openrtb/v2/onboard with Authorization: Bearer 3. GET /openrtb/v2/adslots — browse screens with FEIN signals 4. POST /openrtb/v2/creatives — upload creative (URL or S3 presigned) 5. POST /openrtb/v2/creatives/:id/confirm — trigger AI moderation (upload mode) 6. GET /openrtb/v2/creatives/:id — check moderation status + IAB classification 7. POST /openrtb/v2/campaigns — create campaign with approved creative 8. POST /openrtb/v2/campaigns/assign — place on screens (30-60s delivery) ## Endpoints ### Onboarding POST /openrtb/v2/onboard — Link the supplied platform key to a sandbox DSP seat. Body: { company_name, seat_id, contact_email } Auth: Authorization: Bearer (recommended, reuses key) Legacy: no auth mints a tb_dsp_* key once ### Inventory Discovery GET /openrtb/v2/adslots — Browse screens with live FEIN signals (face count, attention, emotion, VAS). Query: ?venue_type=retail&country=US&min_vas=5.0&min_attention=60 POST /openrtb/v2/forecast — Estimate impressions for budget + targeting. Body: { budget_usd, targeting: { country, venue_type }, start_date, end_date } POST /openrtb/v2/discover — Semantic search ("busy coffee shops in NYC"). Body: { query: "...", limit: 20 } ### Real-Time Bidding POST /openrtb/v2/bid — Submit OpenRTB 2.6 bid response. Validated against floors + blocklists. Bids enter live waterfall auction. Auth: Authorization: Bearer + bid:write (sandbox bids don't enter live auction) GET /openrtb/v2/stats — 24h performance metrics: bids, wins, CPM, latency. ### Bilateral Exchange Bidstream POST /openrtb/bidder/{connection_alias}/bid — Configured supply-side OpenRTB request endpoint. Auth: exchange connection alias/config, optional IP allowlist/mTLS/signature; no platform key per request. GET callback URLs /win, /imp, /loss — use the self-authenticating URLs returned in each bid. ### Creatives (AI Moderation Pipeline) POST /openrtb/v2/creatives — Upload creative. Two modes: URL mode: { name, content_type, creative_url } — moderation starts immediately Upload mode: { name, content_type, filename } — returns presigned S3 URL POST /openrtb/v2/creatives/:id/confirm — After presigned upload, triggers Gemini AI moderation. GET /openrtb/v2/creatives — List creatives. ?status=approved&limit=50 GET /openrtb/v2/creatives/:id — Full creative detail: - AI moderation status (pending/approved/rejected) - Content classification (alcohol, tobacco, gambling, violence, profanity) - Age rating (G/PG/PG-13/R/NC-17) - IAB categories (IAB1-IAB25) - Brand recognition ### Campaigns POST /openrtb/v2/campaigns — Create campaign with approved creative + targeting. Returns matching screens. Body: { creative_id, name, targeting: { country, venue_type, city } } POST /openrtb/v2/campaigns/assign — Place creative on specific screens. Body: { creative_id, screen_ids: [...], start_date, end_date, start_time, end_time } Screens pick up within 30-60 seconds. ### Deals GET /openrtb/v2/deals — List available PMP/preferred/guaranteed deals. GET /openrtb/v2/deals/:dealId — Deal details (floor, caps, dates). POST /openrtb/v2/deals/propose — Propose a PMP deal (admin approval required). Body: { deal_id, floor_cpm, impression_cap, start_date, end_date } ### Reporting GET /openrtb/v2/reports/summary — Impressions, CPM, fill rate, unique screens. ?days=7 GET /openrtb/v2/reports/screens — Per-screen performance breakdown. ?days=7&limit=50 GET /openrtb/v2/reports/timeseries — Hourly or daily trends. ?days=7&granularity=hourly ## Rate Limits Bidding: 1,000 req/min | Inventory: 200 req/min | Campaigns: 100 req/min | Onboard: 5/hr Sandbox mode: 100 req/min for all endpoints. ## Error Codes (DSP API) Every 4xx/5xx response uses the unified `DspErrorResponse` envelope: { success: false, error: "", error_code: "", request_id: "req_", ...extras } Optional `extras` keys: param, retry_after_seconds, required_scope, granted_scopes, nbr (bid only). # Authentication / authorization (4xx — fix the request; do not retry) - authentication_required — API key missing or DSP context not populated. Send `x-api-key` or `Authorization: Bearer `. - not_authorized — API key resolved but does not own the requested resource. - missing_scope — API key lacks the scope required by this route. `extras.required_scope` names the missing scope; `extras.granted_scopes` lists what the key has. # Validation (4xx — fix the payload; do not retry) - missing_required_field — `extras.param` names the missing field. - missing_name — creative or campaign upload missing the `name` field. - missing_content_type — creative upload missing `content_type`. - missing_creative_id — campaign/assignment missing `creative_id`. - missing_floor_cpm — deal proposal missing `floor_cpm`. - invalid_eligible_screens — deal proposal sent an empty `eligible_screens`; send null (every screen) or a non-empty list of screen ids. - missing_screen_ids — assignment missing the `screen_ids[]` array. - missing_asset_url — creative upload missing `asset_url` or `creative_url`. - missing_deal_id — deal lookup missing `deal_id`. - invalid_email — contact_email failed format check on onboarding. - invalid_seat_id — seat_id must match `^[a-z0-9_]+$` and be 3-40 chars. - invalid_screen — screen_id does not resolve to a known Trillboards screen. - invalid_budget — campaign budget out of allowed range or wrong type. - invalid_date — date param is not ISO-8601 or violates start < end. - invalid_webhook_url — webhook endpoint must be https:// and not localhost/private RFC1918. - invalid_verification_tier — bidder requested a verification tier not enabled for their seat. - invalid_bid_response — bid payload failed OpenRTB 2.6 schema validation; see `extras.nbr` for the no-bid reason code. - invalid_events — webhook subscribe request listed events outside the allowlist. # Resource state (4xx — most are not retryable without state change) - seat_id_already_registered — onboarding with a seat_id that already exists; reuse the existing key. - already_exists — generic conflict on a unique-key insert. - deal_already_exists — POST /deals/propose against an existing deal_id. - not_found — resource id not resolvable to anything the seat owns. - creative_not_found — `creative_id` does not exist or is not owned by this seat. - screen_not_found — `screen_id` does not exist. - reservation_not_found — `reservation_id` does not exist or is not owned by this seat. - deal_not_found — `deal_id` does not exist. - webhook_not_found — `webhook_id` does not exist or is not owned by this seat. - expired — reservation has passed its 15-min TTL; create a new one. # Throttling (429 — retry after the indicated delay) - rate_limited — Rate limit exceeded. `extras.retry_after_seconds` carries the cooldown. The `Retry-After` HTTP header is also set. # Server (5xx — retry with exponential backoff; report `request_id` if persistent) - api_key_provisioning_failed — onboard succeeded at the DB layer but the API key minting step failed. Re-call onboard. - internal_error — caught uncaught exception. Retry once; if persistent, email developers@trillboards.com with `request_id`. - unspecified_error — never used in practice; reserved fallback. # Retry guidance summary - 4xx → never retry the same payload. Fix and re-send. - 429 → retry after `extras.retry_after_seconds` (or `Retry-After` header). - 5xx → exponential backoff (e.g. 1s, 2s, 4s, 8s, max 60s) with at most 5 attempts. Include `request_id` from the response in support emails. ## FEIN Signals (Unique to Trillboards) Screens with FEIN sensors provide real-time audience data in ext.trb.fein: - live_face_count: people facing the screen (10s freshness) - attention_score: gaze attention 0-1 (10s freshness) - dominant_emotion: happy/sad/angry/neutral (10s freshness) - vas_7d: Verified Attention Seconds, 7-day rolling average - crowd_density: estimated venue occupancy - purchase_intent: speech-derived intent level --- # Proof-of-Play API # Cryptographically signed ad impression verification ## Overview Every ad impression on Trillboards is signed with Ed25519 cryptography. Advertisers can export proofs and share them with third-party auditors who verify signatures independently using our public key. ## API Base URL Production: https://api.trillboards.com/v1/advertiser/proof ## OpenAPI Specification Interactive docs (Scalar): https://api.trillboards.com/docs#measurement Proof-of-Play OpenAPI spec: https://api.trillboards.com/docs/openapi/proof-of-play.yaml ## Philosophy: Open Verification, Closed Discovery - PUBLIC: Signature verification (anyone can verify a proof they receive) - RESTRICTED: Data discovery (only campaign owners can fetch proofs) ## Public Endpoints (No Auth) ### GET /.well-known/public-key Returns Ed25519 public key for third-party signature verification. Response: ```json { "success": true, "publicKey": "a5a9c43785680c62ebcd01ec49a0d6055fd140023df0f142c3e37c6abafce34f", "algorithm": "ed25519", "version": "v2" } ``` ### POST /verify-proof Verify a signature without needing the private key. Request: ```json { "signature": "ed25519=", "timestamp": 1705401600000, "adId": "campaign-abc123", "impressionId": "507f1f77bcf86cd799439011", "screenId": "screen-xyz789", "deviceId": "device-456" } ``` Response: ```json { "success": true, "verification": { "valid": true, "version": "v2" } } ``` ### GET /signature-info Returns signature format documentation for integrators. ## Authenticated Endpoints (Bearer Token Required) ### GET /campaigns/{campaignId}/proof Get signed proofs for a campaign. Owner only. Supports cursor-based pagination via `cursor` query param. Query params: startDate, endDate, limit (default 1000), cursor ### GET /screens/{screenId}/proof Get proofs for a screen. Screen owner or advertiser who ran campaigns on it. Supports cursor-based pagination. ### GET /campaigns/{campaignId}/audience-proof Get per-screen impression counts for a campaign, with one aggregate signature over the roll-up. Owner only. ### POST /campaigns/{campaignId}/export-proof Export proofs as JSON or CSV. Owner only. ## Signature Format Version: v2 (Ed25519, current) Payload: `version.timestamp.adId.impressionId.screenId.deviceId` Signature prefix: `ed25519=` Example proof object: ```json { "impressionId": "507f1f77bcf86cd799439011", "adId": "campaign-abc123", "screenId": "screen-xyz789", "deviceId": "device-456", "timestamp": "2026-01-16T12:00:00.000Z", "eventType": "complete", "signature": "ed25519=a1b2c3d4e5f6...", "signatureVersion": "v2" } ``` ## Verification Example (Node.js) ```javascript const crypto = require('crypto'); const PUBLIC_KEY_HEX = 'a5a9c43785680c62ebcd01ec49a0d6055fd140023df0f142c3e37c6abafce34f'; function verifyProof(proof) { const payload = `v2.${proof.timestamp}.${proof.adId}.${proof.impressionId}.${proof.screenId}.${proof.deviceId}`; const signatureHex = proof.signature.replace('ed25519=', ''); const signatureBytes = Buffer.from(signatureHex, 'hex'); const publicKey = crypto.createPublicKey({ key: Buffer.concat([ Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(PUBLIC_KEY_HEX, 'hex') ]), format: 'der', type: 'spki' }); return crypto.verify(null, Buffer.from(payload), publicKey, signatureBytes); } ``` ## Verification Example (Python) ```python from nacl.signing import VerifyKey PUBLIC_KEY_HEX = 'a5a9c43785680c62ebcd01ec49a0d6055fd140023df0f142c3e37c6abafce34f' def verify_proof(proof): verify_key = VerifyKey(bytes.fromhex(PUBLIC_KEY_HEX)) payload = f"v2.{proof['timestamp']}.{proof['adId']}.{proof['impressionId']}.{proof['screenId']}.{proof['deviceId']}" signature_bytes = bytes.fromhex(proof['signature'].replace('ed25519=', '')) try: verify_key.verify(payload.encode(), signature_bytes) return True except: return False ``` ## Legacy Support Version v1 (HMAC-SHA256) is deprecated but still supported. Signature prefix: `sha256=` Use v2 Ed25519 for third-party verification. --- --- # Attribution & Measurement API # Closed-loop DOOH attribution with attention-verified impressions ## Overview Trillboards provides the only deterministic DOOH attribution engine: on-device computer vision measures actual human attention, third-party mobile signals prove store visits, and Bayesian experiments measure incremental lift. Every metric is MEASURED, not estimated. ## API Base URL Production: https://api.trillboards.com/v1/advertiser/attribution Authentication: Bearer JWT token ## OpenAPI Specification Interactive docs (Scalar UI): https://api.trillboards.com/docs#measurement Attribution OpenAPI spec: https://api.trillboards.com/docs/openapi/attribution-api.yaml Attribution-focused AI guide: https://api.trillboards.com/llms-attribution.txt ## Key Concepts ### aCPM (Attention CPM) Cost per 1,000 attention-verified impressions. Unlike standard CPM, aCPM only counts impressions where someone was actually looking at the screen, as measured by on-device face detection and gaze tracking. ### Incremental Lift Percentage increase in store visits caused by the campaign, measured via geo-holdout experiments with DMA-matched control groups. Not correlation — causation. ### CPIV (Cost Per Incremental Visit) Total media cost / number of visits that would NOT have happened without the campaign. The true cost of driving a store visit. ### Weighted Confidence base_match_confidence * (1 + attention_weight). Combines spatial proximity with FEIN attention verification for a single confidence score. ## Endpoints Summary ### Campaign Attribution - GET /campaign/{id} — Comprehensive attribution summary (reach, visits, lift, CPIV) - GET /campaign/{id}/timeseries — Daily attribution metrics over time - GET /campaign/{id}/heatmap — Geospatial exposure and conversion density - GET /campaign/{id}/journey — Attribution funnel (exposure -> visit -> transaction) ### Attention Metrics (Unique to Trillboards) - GET /campaign/{id}/attention — aCPM, attention scores, dwell, emotion breakdown - GET /campaign/{id}/attention/creative — Per-creative attention comparison - GET /campaign/{id}/attention/timeseries — Attention fatigue detection over time ### Cross-Channel Attribution - GET /campaign/{id}/multi-touch — Channel credit allocation (DOOH vs mobile vs web vs store) - GET /campaign/{id}/cross-channel-journey — Sankey flow data for path visualization - GET /campaign/{id}/channel-roas — Per-channel ROAS comparison ### Incrementality Measurement - POST /experiment — Create geo-holdout, ghost ad, or PSA control experiment - GET /experiment/{id} — Experiment results (Bayesian + frequentist) - GET /experiment/{id}/power — Sample size needed for target MDE - GET /campaign/{id}/incrementality — Campaign-level Bayesian uplift ### Creative Attribution - GET /campaign/{id}/creative-attribution — Creative x outcome x context correlation - GET /campaign/{id}/creative-attribution/{creativeId}/timeseries — Creative fatigue over time ### Signal Transparency - GET /campaign/{id}/signal-quality — Data coverage, freshness, and confidence - GET /campaign/{id}/methodology — Human-readable explanation of attribution methodology ### Cohort Export - POST /campaign/{id}/cohort/export — Export MAID list to TTD, DV360, or Meta ## Cross-Channel Event Ingestion - POST /v1/attribution/event/mobile — DSP postback (impressions/clicks) - GET /v1/attribution/pixel/{advertiserId}/{campaignId} — 1x1 web tracking pixel - POST /v1/attribution/event/transaction/:sourceId — Transaction/purchase data (per-source HMAC via X-Trill-Signature) --- # OpenRTB 2.6 Signal Extensions # Proprietary signals in every bid request ## Overview Trillboards injects 30+ proprietary signals into OpenRTB 2.6 DOOH bid requests. These signals are MEASURED by on-device edge AI, not estimated. ## Documentation Schema reference: https://api.trillboards.com/docs/guides/openrtb-extensions Schema spec: https://api.trillboards.com/docs/openapi/openrtb-extensions.yaml Executable programmatic routes: https://api.trillboards.com/docs/openapi/programmatic-api.yaml ## Signal Namespaces ### ext.trillboards — Real-Time Audience (10-30s fresh) - attention_score: Fraction of faces looking at screen (0-1, gaze detection) - audience_count: Average face count - crowd_density: Peak face count - ad_receptivity: Audio-derived venue receptivity (0-1, YAMNet TFLite) - emotional_engagement: Facial expression engagement (0-1) - mood: Venue mood (energetic/calm/focused/social/neutral) - estimated_occupancy: Venue occupancy estimate - avg_dwell_ms: Average face dwell time - signals_age_ms: Signal freshness (lower = fresher) - reliability_score: Screen reliability from unified telemetry - audience_quality: Audience data quality score - screen_quality_composite: Combined quality score ### ext.trb.vas — Verified Attention Seconds (7-day rolling) - predicted_vas: Predicted VAS for next impression - historical_vas_avg: 7-day average VAS - median_vas, p90_vas: Distribution metrics - avg_face_count, avg_attention_score: Historical averages ### ext.trb.intent — Purchase Intent (10-30s fresh) - score: Composite intent score (0-1) - purchase_stage: READY_TO_BUY / COMPARING / CONSIDERING / BROWSING / NONE - categories: Detected product interest categories - brand_mentions: Whether brand names detected in ambient audio ### ext.trb.weather — Contextual Weather (60-min cache) - temp_f, humidity, condition, uv_index, wind_mph, is_raining - temp_segment: hot / warm / cool / cold ### ext.trb.foot_traffic — Venue Occupancy (10-30s fresh) - current_audience, estimated_occupancy, avg_dwell_seconds - traffic_tier: very_low / low / medium / high / very_high ### user.data[] — IAB Audience Taxonomy 1.1 Segments Income (1001-1005), Lifestyle (2001-2020), Purchase Intent (3001-3003) Plus seller-defined segments: venue (trb_vn_*), geo (trb_geo_*), traffic (trb_trf_*), dwell (trb_dwl_*), daypart (trb_dpt_*), quality (trb_qly_*) --- # FEIN Edge AI API # On-device computer vision and audio classification ## Overview FEIN (Face-detection Edge Intelligence) runs ML Kit and YAMNet TFLite on CTV tablets. The FEIN API exposes analytics, webhooks, and SSE streaming. ## Documentation Interactive docs (Scalar UI): https://api.trillboards.com/docs#demand Programmatic & FEIN OpenAPI spec: https://api.trillboards.com/docs/openapi/programmatic-api.yaml ## FEIN Analytics (read, X-API-Key auth) - GET /openrtb/v2/fein/creative-venue-performance — Creative x venue matrix - GET /openrtb/v2/fein/fill-rate-by-venue — Fill rates by venue type - GET /openrtb/v2/fein/screen-intelligence/{screenId} — Per-screen intelligence - GET /openrtb/v2/fein/model-status — ML model deployment status - GET /openrtb/v2/fein/audience-segments — Segment distribution - GET /openrtb/v2/fein/inference-history — Historical inference results - GET /openrtb/v2/fein/readiness — System readiness check ## Content Intelligence (read, X-API-Key auth) - GET /openrtb/v2/fein/content-performance/{videoId} — How a video performs across the network (plays, attention, audience, venues) - GET /openrtb/v2/fein/content-recommendations — Best content for a venue + time + audience context - POST /openrtb/v2/fein/content-semantic-search — Natural language search over content embeddings (768-D pgvector) - GET /openrtb/v2/fein/content-audience-correlation — Content category × audience demographic breakdown ## FEIN Webhooks & SSE - POST /openrtb/v2/fein/webhooks — Register webhook for real-time events - GET /openrtb/v2/fein/webhooks — List registered webhooks - DELETE /openrtb/v2/fein/webhooks/{id} — Remove webhook - GET /openrtb/v2/fein/stream — SSE event stream (inference_complete, attribution_updated, model_slice_updated, creative_outcome_updated) --- # Attribution Data Model # 6-layer pipeline from signal to insight ## Documentation Schema reference: https://api.trillboards.com/docs/guides/attribution-data-models Attribution data model spec: https://api.trillboards.com/docs/openapi/attribution-data-models.yaml ## Pipeline Layers ### Layer 1: Signal Ingestion device_signals (Intuizi mobile pings) + poi_reference (brand locations) ### Layer 2: Exposure Matching exposure_device_matches — PostGIS ST_DWithin joins device signals with ad plays ### Layer 3: Attention Enrichment attention_exposure_matches — FEIN edge AI signals joined with exposures ### Layer 4: Identity Resolution identity_nodes + identity_edges + household_clusters — cross-device graph ### Layer 5: Attribution touchpoint_events + attribution_chains — multi-touch credit allocation ### Layer 6: Measurement incrementality_experiments + incrementality_results + creative_attribution_performance --- ## Support - Public API index: https://api.trillboards.com/docs/openapi.yaml - Documentation: https://api.trillboards.com/docs/guides - Email: developers@trillboards.com - Partner API docs: https://api.trillboards.com/docs#partner - Proof-of-Play API docs: https://api.trillboards.com/docs#measurement - Attribution API docs: https://api.trillboards.com/docs#measurement - Programmatic & FEIN docs: https://api.trillboards.com/docs#demand - OpenRTB Extensions docs: https://api.trillboards.com/docs/guides/openrtb-extensions - Data Model docs: https://api.trillboards.com/docs/guides/attribution-data-models - Partner API spec: https://api.trillboards.com/docs/openapi/partner-api.yaml - Proof-of-Play API spec: https://api.trillboards.com/docs/openapi/proof-of-play.yaml - Attribution API spec: https://api.trillboards.com/docs/openapi/attribution-api.yaml - Programmatic & FEIN API spec: https://api.trillboards.com/docs/openapi/programmatic-api.yaml - OpenRTB Extensions schema spec: https://api.trillboards.com/docs/openapi/openrtb-extensions.yaml - Attribution data model spec: https://api.trillboards.com/docs/openapi/attribution-data-models.yaml - Attribution AI guide: https://api.trillboards.com/llms-attribution.txt - Pricing endpoint: https://api.trillboards.com/v1/partner/pricing --- # Machine-Readable Pricing # Usage-based graduated pricing for all products ## Endpoint GET https://api.trillboards.com/v1/partner/pricing No authentication required. Returns the full pricing catalog in JSON. ## MCP Tool Use the `get_pricing` MCP tool (no auth required) to fetch the same data programmatically via MCP. Optionally filter by product: ```json { "name": "get_pricing", "arguments": { "product": "data_api" } } ``` ## Pricing Model All products use graduated usage-based pricing: - **Free tier**: Every product has a generous free tier (no credit card needed) - **Graduated volume**: Unit cost decreases as usage increases - **Committed use**: Pre-commit to monthly spend for 10-40% additional discount - **Annual discount**: Additional 10% off on annual commitments ## Products & Free Tiers | Product | Free Tier | Unit | |---------|-----------|------| | Data API | 10,000/mo | API calls | | Proof of Play | 1,000/mo | Proofs | | Attribution | First campaign | Free trial | | Data Marketplace | 5,000/mo | Queries | | Partner Platform | Unlimited | Free forever (rev share) | | Programmatic | Unlimited | Free (20% media fee) | | FEIN Edge AI | Unlimited | Free for screen owners | ## Usage Tracking Authenticated partners can check usage via: - MCP tool: `get_usage_summary` (requires API key) - REST: GET /v1/partner/usage --- # AI Agent Quick Start (Zero Human Interaction) # 5-step fully automated integration flow ## Step 1: Discover Fetch pricing and capabilities — no signup required: ```bash curl https://api.trillboards.com/v1/partner/pricing curl https://api.trillboards.com/.well-known/adagents.json ``` Or via MCP: ```json { "name": "get_pricing" } { "name": "get_adcp_capabilities" } ``` ## Step 2: Register Create a partner account and receive an API key instantly: ```bash curl -X POST https://api.trillboards.com/v1/partner/register \ -H "Content-Type: application/json" \ -d '{"company_name":"Acme AI","email":"ai@acme.com","contact_name":"AI Agent"}' ``` Or via MCP: ```json { "name": "register_partner", "arguments": { "company_name": "Acme AI", "email": "ai@acme.com" } } ``` Save the returned `api_key` — it is shown only once. ## Step 3: Discover Inventory Browse available screens filtered by venue, location, or audience: ```json { "name": "discover_inventory", "arguments": { "venue_types": ["retail"], "location": { "city": "New York" } } } ``` ## Step 4: Create Campaign Launch a media buy targeting discovered screens: ```json { "name": "create_media_buy", "arguments": { "name": "Q1 Retail Push", "buy_spec": { "venue_types": ["retail"], "budget": { "daily_usd": 100, "bid_cpm": 4.0 } }, "creative": { "url": "https://cdn.acme.com/ad.mp4", "type": "video", "duration_seconds": 15 } } } ``` ## Step 5: Monitor & Optimize Check delivery, provide feedback, log conversions: ```json { "name": "get_media_buy_delivery", "arguments": { "media_buy_id": "mbuy_..." } } { "name": "get_usage_summary" } ``` Total time: Under 60 seconds. No human approval gates. No credit card for free tier. --- # Scored RL environment trials Free source-bound scored trials, with reference runs and actor/verifier environments: https://api.trillboards.com/docs/guides/integrations/rl-environments.md Machine-readable trial downloads, task IDs, ZIP hashes and full-package marketplace links: https://api.trillboards.com/docs/integrations/rl-environments.json The recorded creative-feedback suite's public evaluation run on HUD, with every trace: https://hud.ai/jobs/a5c98ef7-7d39-4920-bd65-a0c521ef10f6 # Creative AI feedback loop Optimize model-generated images against attention and dwell from actual paid screen plays. The self-serve flow uses one partner buying account and the same native media buy, creative moderation, brand review and reporting APIs. 1. Discover inventory and fund a media buy; keep media_buy_id and package_id. 2. create_creative_experiment, then configure_feedback_brand. 3. generate_brand_creative or import_feedback_creative (image URL, same-partner source creative, or inline PNG/JPEG/WebP bytes). 4. review_feedback_creative; register_creative_variants once brand approval and AI moderation complete. Register your immutable model_version with each variant. 5. update_creative_policy with the current policy_version. Eligible updated weights affect the next signed paid play. 6. get_creative_rewards with fixed inclusive dates and a fresh idempotency_key for every page/poll. Follow next_cursor, then rewind after scan_complete. Apply upserts/retractions by canonical_key and deduplicate reward_revision_id. Measured rewards refresh every five minutes, with a daily 48-hour correction sweep. Gaze share is 0–1; dwell/overlap are milliseconds; NULL means unmeasured. creative_feedback retains the issued variant, model, policy, bytes, render and selection probability for each actual play. Media is funded against its contracted inventory/CPM quote. Generation, import and every reward page use the separate existing data_api/api_calls tariff/free quota and return usage_receipt. Free creative.rewards_ready webhook/SSE hints wake your reader; reconnect recovers through a bounded full rescan. A committed operation key returns 409 OPERATION_ALREADY_COMMITTED with its original receipt and server-derived creativeId/renderKey for generation/import. get_creative_experiment exposes the native creatives list (latest 500; creatives_truncated when more exist) for response recovery. Use a new key for new work. Self-service partner account and API key quickstart (Markdown): https://api.trillboards.com/docs/guides/quickstart/partner.md Creative AI Feedback Loop: REST, MCP and TypeScript SDK walkthrough (Markdown): https://api.trillboards.com/docs/guides/integrations/creative-feedback.md Historical HUD packages reuse verified native play exports and immutable vintages; the live HUD adapter calls the same authenticated MCP tools. # MCP Connection Details # Connect from Claude Desktop, Cursor, or any MCP client ## Server URL https://api.trillboards.com/mcp ## Transport Streamable HTTP (MCP Protocol Version 2025-11-25) ## Authentication For the open tools (get_products, get_pricing, get_adcp_capabilities, list_creative_formats, register_partner and the discovery tools): No headers required. For authenticated tools: ``` Authorization: Bearer ``` Protected tools require mcp:tools plus the relevant domain capability (for example inventory:read, reports:read, stats:read, or campaigns:write). Legacy partner scopes preserve owned-screen behavior. Existing header alias: ``` X-Trillboards-API-Key: ``` ## Claude Desktop Configuration Add to your `claude_desktop_config.json`: ```json { "mcpServers": { "trillboards": { "url": "https://api.trillboards.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ## Cursor Configuration Add to your `.cursor/mcp.json`: ```json { "mcpServers": { "trillboards": { "url": "https://api.trillboards.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ## Agent Identification Set `X-MCP-Client-Name` header to identify your agent (optional but recommended): ``` X-MCP-Client-Name: my-trading-desk-agent ``` ## Direct Streamable HTTP Tool Call Initialize/list tools using normal MCP protocol, then invoke tools with JSON-RPC: ```bash curl -sS -X POST https://api.trillboards.com/mcp \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-MCP-Client-Name: my-trading-desk-agent' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "discover_inventory", "arguments": { "venue_types": ["retail"], "limit": 20 } } }' ``` Start with `tools/list`. `get_products` needs no key at all — call it with a natural-language `brief` to read the catalogue before you decide to transact. Then use `discover_inventory`, `get_audience_forecast`, `get_signals`, and `get_network_stats` as allowed by the key's capabilities. Mutation tools such as `activate_signal` and `create_media_buy` require write capabilities. ## Available Tools (Unauthenticated) - get_products — Full DOOH product catalogue. Send a natural-language `brief` ("commuters who are bored and hungry around lunchtime") and each product's description answers it from what the screens actually observed: the hours people are in frame in local time, dwell, the mood/movement/gaze the sensors reported, what speech sensing heard people shopping for, and which of your words we cannot evidence. Products are ordered by that evidence. A key is needed only to buy. - get_pricing — Pricing catalog with free tiers - get_adcp_capabilities — Platform capability discovery - register_partner — Instant partner registration - list_creative_formats — Creative sizes and durations the screens play - list_endpoints, describe_endpoint, validate_request, list_error_codes, query_changelog — The HTTP API, self-described - get_device_ads — Fetch ads for a device ## Screen-Owner Tools (partner key of the screen's owner) - record_impression — Record a single impression - batch_impressions — Record up to 100 impressions - device_heartbeat — Report device online status ## Available Tools (Authenticated) - get_partner_info, register_device, list_devices, get_device, delete_device - get_analytics, get_usage_summary - create_webhook, list_webhooks, update_webhook, delete_webhook, test_webhook, get_webhook_deliveries - discover_inventory, get_live_audience, get_audience_forecast - create_campaign, get_campaign_performance, get_network_stats - get_signals, activate_signal - create_media_buy, update_media_buy, sync_creatives - get_media_buy_delivery, provide_performance_feedback, log_event ## Available Tools (Attribution & Measurement) - get_campaign_attribution — Comprehensive attribution summary (reach, visits, lift, CPIV) - get_attribution_timeseries — Daily attribution metrics over time - get_attention_metrics — aCPM, attention scores, dwell, emotion breakdown - get_creative_attention — Per-creative attention comparison - get_multi_touch_attribution — Channel credit allocation (DOOH vs mobile vs web vs store) - get_cross_channel_journey — Sankey flow data for path visualization - get_incrementality — Campaign-level Bayesian uplift - create_experiment — Create geo-holdout, ghost ad, or PSA control experiment - get_creative_attribution — Creative x outcome x context correlation - get_roas — Per-channel ROAS comparison - export_cohort — Export MAID list to TTD, DV360, or Meta - get_audience_lookalike — Find screens with similar audience profiles - semantic_audience_search — Natural language audience discovery - get_campaign_heatmap — Geospatial exposure and conversion density - verify_proof_of_play — Cryptographic proof verification for impressions --- # Error reference # Source: https://api.trillboards.com/docs/errors.md # Trillboards API Error Reference Every external Trillboards API endpoint returns errors using the canonical envelope below. Phase 1 of the API DX overhaul locks this contract; Phase 2+ migrates each remaining surface onto it. ```json { "error": { "type": "invalid_request_error", "code": "screen_not_found", "message": "Screen 'scr_xxx' not found or access denied.", "param": "screen_id", "doc_url": "https://api.trillboards.com/docs/errors#screen_not_found", "request_id": "req_a1b2c3d4e5f6789012345678901234ab", "details": [ { "param": "screens[0].orientation", "code": "invalid_enum", "message": "Must be 'landscape' or 'portrait'." } ], "help": { "summary": "Screen IDs are prefixed 'scr_'. List your screens at GET /v1/partner/screens.", "suggestions": ["scr_abc123", "scr_def456"], "portal_url": "https://trillboards.com/earner/screens", "try_next": "/api/discover?q=screen" } } } ``` **Field rules** - `type` is one of `invalid_request_error` (4xx client), `authentication_error` (401), `permission_error` (403), `rate_limit_error` (429), `idempotency_error` (409), `api_error` (5xx). - `code` is stable and machine-readable; it never changes across versions. - `details` is present only when multiple field-level violations exist (e.g. a Zod validation result with more than one issue). - `help` is present only when we have structured remediation context. - `request_id` matches the `X-Request-Id` response header and is always set. - `doc_url` is always computable as `https://api.trillboards.com/docs/errors#`. **HTTP status codes never change as part of the body migration.** Callers can opt back into the prior flat envelope `{ statusCode, message, code, param, request_id }` by sending the header `X-Error-Shape: legacy`. This opt-in is removed two weeks after Phase 1 ships. --- ## Authentication Errors ### AUTH_REQUIRED - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: Connect an authenticated native buying account. ### authentication_required - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: No API key or access token was supplied. ```json { "error": { "type": "authentication_error", "code": "authentication_required", "message": "Authentication required.", "param": null, "doc_url": "https://api.trillboards.com/docs/errors#authentication_required", "request_id": "req_…" } } ``` **How to fix**: include `Authorization: Bearer ` on the request. ### invalid_api_key - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The API key was not recognised or is inactive. **How to fix**: rotate the key in the portal and retry with the fresh value. ### invalid_api_key_format - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The API key does not match the expected format. **How to fix**: Partner keys begin with `trb_partner_`. Make sure you copied the full key, including the prefix. ### api_key_expired - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The API key has expired and must be rotated. **How to fix**: rotate the key in the partner portal. ### api_key_revoked - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The API key has been revoked and can no longer be used. **How to fix**: issue a new key in the portal. ### auth_error - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: Generic authentication failure. **How to fix**: verify credentials and retry. Contact support with the `request_id` if the problem persists. --- ## Permission Errors ### CREATIVE_ADVERTISEMENT_NOT_OWNED - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The native advertisement is not owned by the resolved advertiser. ### creative_policy_forbidden - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The native creative policy belongs to another advertiser. ### insufficient_scope - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The API key is valid but lacks the scope required for this endpoint. **How to fix**: inspect `help.required_scope` and issue a key with the needed scopes. ### insufficient_permissions - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The caller does not have permission to perform this action. **How to fix**: contact an account owner to grant access, or use a different key. ### partner_inactive - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The partner account is not currently active. **How to fix**: contact support to reactivate the partner account. ### forbidden - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: Access denied. **How to fix**: verify the resource belongs to the authenticated caller. ### tier_insufficient - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: This endpoint requires a higher data-access tier than the caller has. **How to fix**: upgrade the data-access tier. `help.required_tier` indicates the minimum tier needed. --- ## Validation & Request Errors ### INVALID_FEEDBACK_CREATIVE - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Supply exactly one valid HTTPS URL, owned creative ID or base64 image with the native creative fields. ### SOURCE_CREATIVE_NOT_FOUND - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The source creative ID has no asset in this buyer library. ### UNSUPPORTED_CREATIVE_TYPE - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The actual creative bytes or declared media type are unsupported for this native import or serving path. ### INVALID_CREATIVE_URL - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The creative download URL must use HTTPS and resolve to a public destination. ### CREATIVE_TOO_LARGE - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The creative exceeds the existing native media byte budget. ### INVALID_FEEDBACK_REQUEST - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: A required native creative-feedback field is invalid. ### EXPERIMENT_NOT_FOUND - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The native experiment is absent or belongs to another buyer. ### INVALID_FEEDBACK_OBJECTIVE - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Choose gaze_share, gaze_overlap_ms or dwell_ms. ### MEDIA_BUY_NOT_FOUND - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The native media package is absent or belongs to another buyer. ### INVALID_POLICY_VERSION - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Supply an exact nonnegative decimal BIGINT policy version string. ### INVALID_CREATIVE_WEIGHTS - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Use unique registered creatives with finite weights 0–1 and at least one positive weight. ### INVALID_VARIANTS - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Use 1–100 unique native variant and creative identities. ### BRAND_PROFILE_NOT_FOUND - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The native brand profile is absent or belongs to another buyer. ### INVALID_BRAND_RULES - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The brand rules do not satisfy the existing native BrandRulesSchema. ### invalid_creative_policy - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The native creative policy owner, mode, version or weight generation is invalid. ### validation_error - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Request data failed validation. See `details[]` for per-field violations. ```json { "error": { "type": "invalid_request_error", "code": "validation_error", "message": "2 validation errors occurred.", "param": "email", "details": [ { "param": "email", "code": "invalid_string", "message": "Invalid email" }, { "param": "age", "code": "too_small", "message": "Must be >= 18" } ], "doc_url": "https://api.trillboards.com/docs/errors#validation_error", "request_id": "req_…" } } ``` **How to fix**: inspect every entry in `details[]` and resubmit with corrected values. ### not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The requested resource does not exist or is not visible to the caller. **How to fix**: list resources first to find a valid ID. ### IDEMPOTENCY_CONFLICT - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The enrollment key belongs to different arguments. ### CREATIVE_POLICY_CONFLICT - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: Refresh the native policy: its version or owner changed. ### EXPERIMENT_COMPLETED - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The native creative experiment has stopped. ### CREATIVE_NOT_READY - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The native creative must be brand approved, moderated and linked to the funded advertisement. ### VARIANT_IDENTITY_CONFLICT - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: An existing variant keeps its creative bytes and model identity. ### OPERATION_IN_PROGRESS - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The metered native operation is still in progress; do not execute it again under another key. ### OPERATION_ALREADY_COMMITTED - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The metered native operation already committed; its existing usage receipt is returned in help.usage_receipt. ### BRAND_PROFILE_CONFLICT - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The supplied native brand profile belongs to another campaign or owner scope. ### CREATIVE_NOT_LINKED - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: Explicit brand approval did not link the creative into the enabled native campaign. ### creative_policy_conflict - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The native creative policy version, mode or integration owner changed. ### FEEDBACK_POLICY_REQUIRED - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: Use the versioned partner creative-feedback methods for this funded experiment. ### conflict - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The requested change conflicts with the current state of the resource. **How to fix**: re-fetch the resource and retry with the latest version. ### missing_required_field - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: A required field was not supplied. **How to fix**: inspect `param` and include the missing field in the request body. ### invalid_field_value - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: A field was supplied with an invalid value. **How to fix**: consult the OpenAPI schema for the accepted set of values. ### invalid_field_type - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: A field had the wrong JSON type. **How to fix**: ensure the field type matches the schema (string/number/boolean/array). ### preferences_must_be_object - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The `preferences` field must be a JSON object (got a non-object type). **How to fix**: send `preferences` as a JSON object (`{ ... }`), not an array, string, or number. ### field_must_be_boolean - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The named field must be a boolean `true` or `false`. **How to fix**: pass the field as a JSON boolean, not as `"true"`/`"false"` strings or `0`/`1`. ### field_must_be_array - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The named field must be an array. **How to fix**: wrap the value in square brackets — `[value]` even for a single entry. ### array_items_must_be_strings - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Every element of the named array must be a string. **How to fix**: cast numeric or object elements to strings before sending, or remove them. ### lbar_settings_must_be_object - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The `lbar_settings` field must be a JSON object. **How to fix**: send `lbar_settings` as a JSON object (`{ ... }`), or omit the field to use defaults. ### pip_settings_must_be_object - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The `pip_settings` field must be a JSON object. **How to fix**: send `pip_settings` as a JSON object (`{ ... }`), or omit the field to use defaults. ### invalid_json - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The request body was not valid JSON. **How to fix**: send a JSON body and set `Content-Type: application/json`. ### field_too_long - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: A string field exceeded its maximum length. **How to fix**: shorten the offending field. ### array_too_large - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: An array field exceeded the maximum number of allowed items. **How to fix**: split the request into multiple batches. ### invalid_array_items - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: One or more items in an array had an invalid type or value. **How to fix**: inspect each item and ensure all match the schema. ### conflicting_lists - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The same value appeared in both an allow list and a block list. **How to fix**: remove the duplicate entry from one of the lists. ### screen_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Screen not found or access denied. **How to fix**: call `GET /v1/partner/screens` to list valid screen IDs. ### invalid_screen_id - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Screen ID is not in the expected format. **How to fix**: screen IDs are alphanumeric strings — verify you are not prepending prefixes or trailing spaces. ### preset_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Content preset not found. **How to fix**: list available presets via `GET /v1/partner/content-presets`. ### preset_id_required - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `preset_id` is required. **How to fix**: supply a valid `preset_id` in the request body. ### preferences_required - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Either `preferences` or `preset_id` is required. **How to fix**: include one of the two fields in the request body. ### screen_ids_required - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `screen_ids` must be a non-empty array. **How to fix**: pass at least one screen ID. ### batch_size_exceeded - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: More screens were sent in one request than the batch limit allows. **How to fix**: split the request into smaller batches. ### invalid_preferences - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `preferences` must be an object. **How to fix**: supply `preferences` as a JSON object. ### invalid_preference_level - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `preference_level` is not one of the allowed values. **How to fix**: use one of the supported preference levels. ### invalid_age_rating - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `max_age_rating` is not one of the allowed values. **How to fix**: use one of the supported age ratings. ### invalid_display_mode - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `display_mode` is not one of the allowed values. **How to fix**: use one of the supported display modes. ### invalid_orientation - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `screen_orientation` is not one of the allowed values. **How to fix**: use `landscape` or `portrait`. ### invalid_sizing_mode - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `advertisement_sizing` is not one of the allowed values. **How to fix**: use one of the supported sizing modes. ### invalid_lbar_position - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `l_bar.position` is not one of the allowed values. **How to fix**: choose one of the supported l-bar positions. ### invalid_pip_position - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `pip.position` is not one of the allowed values. **How to fix**: choose one of the supported picture-in-picture positions. ### invalid_ad_surface_mode - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `ad_surface_mode` is not one of the allowed values. **How to fix**: choose one of the supported ad-surface modes. ### ad_interval_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `ad_interval_seconds` is outside the supported range. **How to fix**: clamp the interval to the documented min/max. ### lbar_size_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `l_bar.size_percent` is outside the supported range. **How to fix**: clamp the size to the documented min/max. ### pip_size_ratio_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `pip.size_ratio` is outside the supported range. **How to fix**: clamp the ratio to the documented min/max. ### pip_margin_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `pip.margin_px` is outside the supported range. **How to fix**: clamp the margin to the documented min/max. ### invalid_lbar_settings - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `l_bar` must be an object. **How to fix**: pass `l_bar` as a JSON object. ### invalid_pip_settings - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `pip` must be an object. **How to fix**: pass `pip` as a JSON object. ### invalid_currency - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `currency` is not one of the allowed values. **How to fix**: use a supported ISO-4217 currency code. ### invalid_provider - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `provider` is not one of the allowed values. **How to fix**: choose one of the supported header-bidding providers. ### floor_price_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `floor_price_cpm` is outside the supported range. **How to fix**: clamp `floor_price_cpm` to the documented min/max. ### allocation_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `programmatic_allocation_percent` is outside the supported range. **How to fix**: clamp the value to the documented min/max. ### timeout_out_of_range - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: `timeout_ms` is outside the supported range. **How to fix**: clamp `timeout_ms` to the documented min/max. ### blocked_advertisers_too_large - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Too many blocked advertisers. **How to fix**: split the blocklist or remove stale entries. ### blocked_iab_categories_too_large - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Too many blocked IAB categories. **How to fix**: split the blocklist or remove stale entries. ### webhook_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Webhook endpoint not found. **How to fix**: list webhooks and confirm the ID. ### invalid_webhook_url - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The webhook URL must be a valid HTTPS URL. **How to fix**: supply an `https://` URL. ### webhook_url_unreachable - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The supplied webhook URL did not respond. **How to fix**: confirm DNS, TLS, and that the target returns 2xx on a health probe. ### invalid_webhook_events - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The supplied webhook event list contains unsupported values. **How to fix**: consult the webhook documentation for supported event types. ### webhook_limit_exceeded - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The maximum number of webhooks for this partner has been reached. **How to fix**: delete unused webhooks before creating new ones. ### webhook_secret_expired - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The webhook signing secret has expired. **How to fix**: rotate the signing secret. ### device_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Device not found or access denied. **How to fix**: list devices first to find a valid ID. ### invalid_device_id - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Device ID is not in the expected format. **How to fix**: confirm the device ID format with documentation. ### device_already_registered - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The device is already bound to another screen. **How to fix**: unbind the device from its current screen before re-registering, or register a new device. ### invalid_fingerprint - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Device fingerprint is not in the expected format. **How to fix**: confirm the fingerprint format with documentation. ### stream_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Stream not found or access denied. **How to fix**: list streams first to find a valid ID. ### invalid_stream_type - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Stream type is not one of the allowed values. **How to fix**: use a supported stream type. ### stream_content_required - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Stream content (video URL, images, etc.) must be provided. **How to fix**: supply at least one content field. ### invalid_media_url - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Media URL is not a valid URL. **How to fix**: supply a valid absolute URL. ### insufficient_data - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Not enough audience data has been collected for this screen yet. **How to fix**: retry after the screen has accumulated more impressions. ### segment_not_found - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: Audience segment not found. **How to fix**: list segments first to find a valid ID. ### segment_limit_exceeded - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: Maximum number of audience segments reached. **How to fix**: delete unused segments before creating a new one. ### invalid_segment_rules - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: One or more segment rules are invalid. **How to fix**: every rule must have `field`, `operator`, and `value`. --- ## Rate Limiting ### rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: Too many requests in the current window. Retry after the advertised time. Rate-limit responses also set the `Retry-After` header. **How to fix**: back off for the seconds indicated by `Retry-After`, then retry. ### daily_quota_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: The caller has exhausted their daily API quota. **How to fix**: wait until the next UTC day or upgrade the plan. ### concurrent_request_limit - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: Too many concurrent requests are in flight for this caller. **How to fix**: reduce request concurrency. --- ## Concurrency & Idempotency ### resource_locked - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The resource is currently being modified by another request. **How to fix**: retry after the in-flight request completes. ### stale_data - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: The resource has been modified since it was last fetched. **How to fix**: re-fetch the resource and resend the update with the new version. ### idempotency_key_in_use - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: A request with this idempotency key is already in flight. **How to fix**: retry with the same key once the previous request completes, or use a fresh key. ### idempotency_key_mismatch - **Type**: `idempotency_error` - **HTTP status**: 422 - **Summary**: The idempotency key was reused with a different request body. **How to fix**: either match the original body or use a fresh idempotency key. ### idempotency_error - **Type**: `idempotency_error` - **HTTP status**: 409 - **Summary**: Generic idempotency failure. **How to fix**: consult the more specific idempotency_* codes and retry. --- ## API Errors ### internal_error - **Type**: `api_error` - **HTTP status**: 500 - **Summary**: An unexpected server error occurred. See `request_id` for the log correlation token. The message never leaks server internals. Always include the `request_id` when filing a support ticket. **How to fix**: retry after a backoff. If the issue persists, contact support with the `request_id`. ### database_error - **Type**: `api_error` - **HTTP status**: 500 - **Summary**: A database error occurred. **How to fix**: retry after a backoff. ### service_unavailable - **Type**: `api_error` - **HTTP status**: 503 - **Summary**: A downstream service is temporarily unavailable. **How to fix**: retry after a short backoff. ### CREATIVE_MODERATION_UNAVAILABLE - **Type**: `api_error` - **HTTP status**: 502 - **Summary**: The image bytes were staged but native moderation publication failed; the API allowance is released. Retry the bytes with a new operation key. ### POLICY_NOT_AVAILABLE - **Type**: `api_error` - **HTTP status**: 503 - **Summary**: The native campaign policy is unavailable. ### BRAND_CONFIGURATION_FAILED - **Type**: `api_error` - **HTTP status**: 503 - **Summary**: The native brand profile could not enable this funded reactive advertisement. ### device_offline - **Type**: `api_error` - **HTTP status**: 503 - **Summary**: The device is currently offline and cannot answer the request. **How to fix**: retry once the device reconnects, or send the command via a queueable endpoint. ### analytics_unavailable - **Type**: `api_error` - **HTTP status**: 503 - **Summary**: The audience analytics service is temporarily unavailable. **How to fix**: retry after a short backoff. ### timeout - **Type**: `api_error` - **HTTP status**: 504 - **Summary**: The upstream operation exceeded its time budget. **How to fix**: retry. If the problem persists, contact support with the `request_id`. ### error - **Type**: `api_error` - **HTTP status**: 500 - **Summary**: Unspecified error. Callers should inspect `message` and retry if appropriate. **How to fix**: fall back to retry with backoff; escalate with `request_id` if the issue is persistent. ### device_identity_underivable - **Type**: `api_error` - **HTTP status**: 400 - **Summary**: `POST /v1/partner/device` (V2 registration) could not derive a canonical device identity — make, model, and OS — from the supplied `ua` string and `hardware` overrides. **How to fix**: check `param` (a comma-separated list of the fields that could not be resolved, e.g. `make,model`), then either send a real browser/WebView User-Agent string in `ua`, or supply `hardware.make` / `hardware.model` / `hardware.os` explicitly to bypass UA parsing entirely. --- ## Data API Errors specific to the `/v1/data/*` surface (Phase 2 migration). ### screen_not_in_scope - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The requested screen is outside the API key's `scoped_screen_ids` allow-list. **How to fix**: use a screen whose ID is listed in your API key's `scoped_screen_ids`, or issue a new key without screen-level scoping. `GET /v1/data/screens` returns the full set of screens the current key can see. ### dataset_not_scopable - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The FEIN DSL dataset does not support screen-level scoping. **How to fix**: the dataset (for example `creative_venue_performance`) is aggregated at venue granularity and cannot be restricted by screen. Either remove screen-level scoping from the key or query a dataset with a `screen_mongo_id` column. ### invalid_dataset - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The `dataset` body field is not one of the allowed FEIN DSL datasets. **How to fix**: read `help.allowed` on the error payload for the list of valid dataset names. ### invalid_dimensions - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: One or more requested dimensions are not defined on the target dataset. **How to fix**: `help.allowed` lists the dimensions defined on the dataset; use only those. ### invalid_measures - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: One or more requested measures are not defined on the target dataset. **How to fix**: `help.allowed` lists the measures defined on the dataset; use only those. ### invalid_order_by - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The `order_by` field is not in the selected dimensions/measures. **How to fix**: every `order_by` entry must reference a field that is also listed in `dimensions` or `measures`. --- ## SDK API Errors specific to the `/v2/sdk/*` measurement surface (Phase 4 migration). ### client_ip_required - **Type**: `invalid_request_error` - **HTTP status**: 400 - **Summary**: The request did not carry a usable client IP. **How to fix**: the SDK derives household identity and geolocation from the client IP. Set `X-Forwarded-For` (via an upstream proxy) or send the request from a direct connection so `getClientIP(req)` can resolve a valid value. ### api_key_owner_missing - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The authenticated API key has no associated partner/owner. **How to fix**: rotate the key in the portal. Missing owner IDs usually indicate a test key that was created without a partner row and should be replaced with a production key. ### sdk_enrich_rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: `/v2/sdk/enrich` exceeded 300 requests per minute for this API key. **How to fix**: batch enrichment client-side and back off for `Retry-After` seconds. Cache the returned segments locally for the household lifetime of the request IP (24h TTL server-side). ### sdk_qr_scan_rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: `/v2/sdk/qr/scan` exceeded 100 requests per minute for this API key. **How to fix**: a QR campaign should not produce more than ~100 scans per minute per publisher — if you are seeing this frequently, investigate whether the same QR is being mis-scanned by a crawler and coalesce client side before posting. ### sdk_qr_beacon_rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: `/v2/sdk/qr/beacon` exceeded 200 requests per minute for this API key. **How to fix**: beacons should fire once per scan. Back off for `Retry-After` seconds; if the limit is hit at steady state, request a quota lift via the portal. ### sdk_impression_rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: `/v2/sdk/impression` exceeded 1000 requests per minute for this API key. **How to fix**: coalesce impressions client-side or split traffic across multiple API keys with per-publisher scoping. --- ## Device API Errors specific to the `/v2/device/*` managed-device control surface (Phase 4 migration). ### unknown_agent_type - **Type**: `invalid_request_error` - **HTTP status**: 404 - **Summary**: The requested device agent type has no OTA manifest entry. **How to fix**: omit the `agent` query parameter to receive the full manifest bundle, or supply one of the documented agent types (`agent-core`, `agent-core-lite`, `tablet-agent`, `android-tv-agent`). --- ## Sensing API Errors specific to the `/v2/sensing/*` paired-training surface (Phase 4 migration). ### paired_training_rate_limit_exceeded - **Type**: `rate_limit_error` - **HTTP status**: 429 - **Summary**: `/v2/sensing/paired-training-samples` exceeded 30 uploads per minute per device. **How to fix**: the collector should batch ~500 samples per call and space uploads across minutes. Back off for `Retry-After` seconds before the next attempt. ### device_identity_missing - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: Device identity is missing after `requireDeviceRequestAuth` passed. **How to fix**: ensure the device-token middleware is configured correctly. If `deviceAuth.deviceId` is null, the resolved device has no canonical identity and cannot be attributed — contact support with the `request_id`. ### paired_training_insert_failed - **Type**: `api_error` - **HTTP status**: 500 - **Summary**: Persisting the paired-training sample batch failed server-side. **How to fix**: retry with backoff — the endpoint is idempotent on `sample_id` so duplicate uploads from a previously partially-written batch are silently deduped. ## OpenRTB / DSP ### openrtb_auth_failed - **Type**: `authentication_error` - **HTTP status**: 401 - **Summary**: The OpenRTB API key is missing or invalid. **How to fix**: supply a valid API key via the `x-api-key` header or `Authorization: Bearer `. Keys are provisioned at DSP onboarding. ### openrtb_config_missing - **Type**: `api_error` - **HTTP status**: 500 - **Summary**: The server's OPENRTB_API_KEY environment variable is not configured. **How to fix**: this is a server configuration issue. Contact support with the `request_id` from the response. ### unsupported_media_type - **Type**: `invalid_request_error` - **HTTP status**: 415 - **Summary**: POST requests must set `Content-Type: application/json`. **How to fix**: add the `Content-Type: application/json` header to your request. ### dsp_access_denied - **Type**: `permission_error` - **HTTP status**: 403 - **Summary**: The DSP account has been suspended or revoked. **How to fix**: contact support@trillboards.com to resolve your account status. ### 204 No Content (no-fill) The OpenRTB surface returns **HTTP 204 No Content** (empty body) when no ad fill is available. This is canonical OpenRTB behavior and is NOT an error. Callers must not treat 204 as a failure — it means no matching inventory was available for the bid request parameters at the time of the request. --- # Monetization requirements # Source: https://api.trillboards.com/docs/monetization-requirements.md # Partner Device Monetization Requirements Every partner device registration and lookup response (`POST /v1/partner/device`, `POST /v1/partner/devices/batch`, `GET /v1/partner/device/:id`, `GET /v1/partner/devices`) carries a `monetization_status` and a structured `requirements` object — modeled directly on Stripe's `account.requirements.currently_due`. Instead of silently accepting a registration that can never actually serve ads, the response tells you exactly what's missing and how to fix it. ```json { "monetization_status": "blocked", "requirements": { "currently_due": [ { "code": "location_awaiting_device_gps", "field": "location", "reason": "awaiting_device_gps", "message": "This mobile screen has no location yet. Send a GPS fix in the heartbeat (location.lat/lng) and it resolves automatically.", "doc_url": "https://api.trillboards.com/docs/monetization-requirements#location_awaiting_device_gps" } ], "eventually_due": [] } } ``` **`monetization_status` values** - **`ready`** — the screen has its own coordinates AND its venue is classified. Fully monetizable. - **`limited`** — servable now, but not on every rail: the venue is not yet classified (missing venue taxonomy / floor-price refinement), or the screen has no position of its own yet and is listed in inventory feeds at your fleet's market location (see [Location capability](#location-capability)). - **`blocked`** — no rail can serve it: no usable location for any rail, or venue classification permanently failed. **`currently_due`** entries block (or limit) monetization right now. **`eventually_due`** entries don't block serving — they improve creative selection / targeting quality and are worth fixing, but there's no rush. A device is never refused registration for missing location: it is created, and the response says which rails can serve it until a position arrives. ## Location capability Every response also carries `location_capability`: where the screen's location comes from, and, per rail, whether that rail has the location it needs. ```json { "location_capability": { "position": "fleet_market", "rails": { "inventory_feed": { "status": "ready", "location_source": "fleet_market", "reason": null }, "geo_targeted_demand": { "status": "blocked", "location_source": null, "reason": "awaiting_device_gps" }, "visit_attribution": { "status": "blocked", "location_source": null, "reason": "awaiting_device_gps" } } } } ``` **`position`** — `precise` (the screen or its device has coordinates), `fleet_market` (no coordinates of its own; your account's declared fleet market location lists it), or `none`. | Rail | What it reads | Ready from | | --- | --- | --- | | `inventory_feed` | The programmatic player feed, which accepts a fleet market location | The screen's or device's own coordinates (`screen`, `device`), or your fleet's declared market location (`fleet_market`) when it is complete and its state can be expressed as an ISO 3166-2 code | | `geo_targeted_demand` | Geo-targeted programmatic demand, and the marketplace placement registries that publish a screen's own coordinate | The screen's or device's own coordinates (`screen`, `device`) | | `visit_attribution` | Visit-attribution location data | The screen's own coordinates and a supported country (`screen`) | `monetization_status` is computed over `inventory_feed` and `geo_targeted_demand`; `visit_attribution` is a measurement capability and does not change it. A blocked rail's **`reason`**: - `awaiting_device_gps` — a mobile screen with no fix yet. Send a GPS fix in the heartbeat (`location.lat` / `location.lng`); every rail that needs the screen's own position becomes ready. - `no_location_provided`, `city_without_state`, `geocode_failed` — a static screen whose location could not be resolved (see [location_unresolvable](#location_unresolvable)). - `screen_position_pending` — the device has reported a position and the screen's own coordinates have not been filled from it yet; the next GPS fix fills them. - `country_unknown` — the rail needs the screen's country. Send `location.country` (ISO 3166-1 alpha-2). - `country_not_supported` — visit-attribution data is not available in the screen's country. --- ### location_awaiting_device_gps - **Field**: `location` - **Blocks monetization**: yes (`blocked`), or `limited` when your account has declared its fleet market location, which lists the screen in inventory feeds until its own fix arrives - **Summary**: This is a mobile or semi-mobile screen (`mobility_type`) with no usable coordinates yet, on either the screen record or the device record. **How to fix**: this is self-healing — no separate API call needed. Include a `location` object with a real GPS fix in the device's heartbeat body: ```json { "location": { "lat": 45.5017, "lng": -73.5673, "accuracy_m": 12, "heading": 180, "speed_mps": 8.3, "source": "gps", "timestamp": 1784800000 } } ``` `lat`/`lng` (or `latitude`/`longitude`) are the only required fields; the rest are optional. The very next heartbeat that includes a valid fix resolves this requirement automatically. Note this is heartbeat-only — `location` is not read from the device registration/PATCH body's `location.lat/lng` fields for this particular gate on mobile screens, since a one-time registration-time coordinate goes stale as a moving screen travels. ### location_unresolvable - **Field**: `location` - **Blocks monetization**: yes (`blocked`), or `limited` when your account has declared its fleet market location (see [Location capability](#location-capability)) - **Summary**: This is a static (non-mobile) screen with no usable coordinates, and what was provided couldn't be geocoded. The `reason` sub-field distinguishes three cases: - `no_location_provided` — no `location.lat/lng`, street address, or city+state was sent at all. - `city_without_state` — only a `city` was sent; a city alone cannot be geocoded. - `geocode_failed` — an address was sent but couldn't be resolved to coordinates. **How to fix**: on `POST`/`PATCH /v1/partner/device`, send `location.lat` / `location.lng` directly, or a full street address, or at minimum `location.city` + `location.state`. ### enrichment_failed - **Field**: `venue` - **Blocks monetization**: yes (`blocked`) - **Summary**: Automated venue classification permanently failed for this screen (`enrichment_status: "permanently_failed"`). **How to fix**: verify the location and venue details are accurate and re-save the device. If it fails again, contact support with the device's `external_device_id` and `screen_id`. ### display_size_missing - **Field**: `display` - **Blocks monetization**: no (`eventually_due` — servable, just not ideal) - **Summary**: `display.width` / `display.height` (pixels) were never sent, so creatives can't be sized precisely for this screen. **How to fix**: send `display.width` and `display.height` in pixels on `POST /v1/partner/device` (registration) or `PATCH /v1/partner/device/:id` (update anytime after registration). This is not an automatic/heartbeat field — the SDK must report it explicitly. --- # Inventory security & venue verification # Source: https://api.trillboards.com/docs/inventory-security.md # Inventory Security & Venue Verification How a screen becomes sellable inventory on Trillboards, what blocks it, and how a demand partner can verify our delivery without trusting our reporting. This page is the plain-language companion to three machine-readable references: the [Partner API](https://api.trillboards.com/docs) (registration contract), the [monetization requirements reference](https://api.trillboards.com/docs/monetization-requirements) (the gate, code by code), and the [proof-of-play specification](https://api.trillboards.com/docs/proof-of-play) (independent verification). --- ## The short version Our content-management app is free and open — anyone can install it on any screen. **Installing the app does not create inventory.** A screen becomes monetizable only after it clears an automated eligibility gate and a human review, and it stays monetizable only while it keeps producing venue-shaped telemetry. If you are evaluating us for supply quality, the two things worth your time are §3 (the monetization gate, which is machine-enforced and publicly documented) and §6 (cryptographic proof of play, which lets you audit us without taking our word for anything). --- ## 1. Two supply routes, two governance models **Contracted networks.** Established operators integrate our ad-serving stack into their own players under commercial agreement, with diligence performed at the network level. For this inventory we declare the **partner's** original bundle ID to demand — never ours — so the supply chain stays attributable end-to-end. Every declared bundle appears in [`trillboards.com/ads.txt`](https://trillboards.com/ads.txt) and [`app-ads.txt`](https://trillboards.com/app-ads.txt), with ownership in [`sellers.json`](https://trillboards.com/sellers.json). **Self-registered venues.** Independent businesses — gyms, restaurants, bars, laundromats, convenience stores — that sign up directly. This is the long tail, and it is governed per-screen by the gates below before anything is sellable. ## 2. Registration binds a screen to a physical device A screen cannot be registered in the abstract: - Registration must bind to a **live device already displaying a pairing code**. A record with no real device behind it cannot be created. - The database enforces **one device, one screen**. A second registration against the same device is rejected outright. That second constraint is why an impression multiplier cannot be built on our stack. On networks where several media players sit behind one physical display, the same audience can be billed several times over and nothing downstream can detect it. Here the binding is one-to-one at the data layer. Registration also requires a structured record — location (`lat`/`lng`, address, city, state, country, timezone), venue classification (category, environment type, placement), display geometry, mobility type, and venue photographs. The full contract is published as `POST /v1/partner/device` in the [Partner API reference](https://api.trillboards.com/docs). Physical dimensions are plausibility-checked at the API boundary. ## 3. The monetization gate is machine-enforced and publicly documented Every device registration and lookup returns a `monetization_status` and a structured `requirements` object, modeled on Stripe's `account.requirements.currently_due`. Rather than silently accepting a registration that can never serve, the response states exactly what is missing. | Status | Meaning | | --- | --- | | `ready` | Coordinates present **and** venue classified. Monetizable. | | `limited` | Not yet classified, or no coordinates of its own yet and listed in inventory feeds only at its operator's declared fleet market location. Servable, with reduced targeting. | | `blocked` | Cannot serve. | Blocking conditions include a screen whose submitted address **could not be geocoded to real-world coordinates**, and a screen whose **automated venue classification permanently failed**. A mobile screen that has never reported a genuine GPS fix is also blocked, unless its operator has declared the fleet's market location: inventory feeds then list it at that market (`limited`), and geo-targeted demand waits for its own fix. Each condition has a stable code, a documented cause and a documented resolution path. The canonical, always-current list lives at [api.trillboards.com/docs/monetization-requirements](https://api.trillboards.com/docs/monetization-requirements) — a reviewer can read the blocking codes off a live API response rather than taking a policy description on faith. ## 4. Human approval, default deny The automated gate is necessary, not sufficient. Every self-registered screen begins **outside** the sellable pool and is reviewed by our team before it enters it. An unapproved screen is not a screen that bids and loses. It is excluded from every inventory feed, every DSP-facing endpoint and every SSP export we publish — demand never sees it. Approval can be revoked, and a screen-level kill switch removes inventory immediately. ## 5. Venue verification against submitted evidence Venue claims are checked against evidence rather than accepted as declared: - **Photographs are machine-graded** for whether they depict a real commercial display in a real venue. Submissions that do not are rejected. - **Venue type is independently classified** from the submitted evidence, then mapped to the OpenOOH taxonomy — the same taxonomy Google and the IAB use to distinguish DOOH from CTV. - **Location, venue type and device attestation travel in the bid request**, alongside IP, so a buyer can target and audit on the same fields we verified. ## 6. Proof of play you can verify yourself Every proof-of-play record is signed **on the device** with an Ed25519 key held in a hardware-backed keystore, and is verifiable by any third party against a public key we publish openly. | Resource | URL | | --- | --- | | Public verification key | [`/v1/advertiser/proof/.well-known/public-key`](https://api.trillboards.com/v1/advertiser/proof/.well-known/public-key) | | Specification | [api.trillboards.com/docs/proof-of-play](https://api.trillboards.com/docs/proof-of-play) | Fetch the key, rebuild the payload as `version.timestamp.adId.impressionId.screenId.deviceId`, and verify the signature. Any delivery record we hand you can be checked independently. If it does not verify, we did not serve it. ## 7. Why a residential screen cannot quietly become inventory The failure mode this page exists to address is a room full of devices monetized as venues. Beyond the gates above, that pattern does not survive contact with our measurement layer. Our screens carry on-device AI that measures who is physically in front of the display, and ads are served against measured presence. A living room does not produce venue-shaped telemetry: no unique-visitor churn, no crowd, no dwell distribution, no foot traffic pattern. Inventory that cannot produce that signal is visible to us as an anomaly rather than invisible as a line in a spreadsheet. This is the structural difference between our supply and an inventory file of declared venue types. On a static file, "is this screen really in a bar?" has no answer after onboarding. Here it is answered continuously, on every impression, in a record the buyer can verify. --- ## Snapshot Figures below are a point-in-time snapshot, refreshed periodically. The mechanisms above are the durable commitment; these numbers illustrate that the gates carry real load. *As of 5 August 2026:* - **24,691** approved screens; **1,065** registered screens held unapproved and earning nothing; **92** deactivated after review - **496 of 4,136** graded venue photographs rejected as not depicting a qualifying screen - **11,859** screens venue-classified from submitted evidence rather than self-declaration - Of screens transacting programmatically in the trailing 30 days, **99.2%** carried lat/long and **99.97%** a classified venue type in the bid request - **4,240** sensing-equipped screens; **28.6M** measurement windows; **2.02 billion** face observations recorded to date --- ## Contact Quality, compliance or supply-path questions: **api@trillboards.com**. We are able to provide demand partners with a dedicated seat drawn only from the verified pool, so that inventory which has not cleared these controls cannot appear in what you resell. --- # Partner API Quickstart # Source: https://api.trillboards.com/docs/guides/quickstart/partner.md # Partner API Quickstart The Partner API (`/v1/partner`) lets publishers manage screens and lets buyers optimize AI creatives against measured attention and dwell on paid plays. Use the same account for inventory, native creative review, serving, webhooks and reporting. **Base URL:** `https://api.trillboards.com` ## 1. Get an API key Register a new partner via `POST /v1/partner/register` (self-service, rate-limited to 5 req/hr per IP) or upgrade an existing earner account via `POST /v2/earner/upgrade-to-partner`. Save the token — the raw key is shown once. For a new organization, replace the company name, unique lowercase slug and contact email: ```bash curl -sS -X POST https://api.trillboards.com/v1/partner/register \ -H 'Content-Type: application/json' \ -d '{"name":"Your Company","slug":"your-company-unique-slug","contact_email":"developer@your-company.example"}' ``` Save `data.api_key` securely as `TRILLBOARDS_API_KEY` in your server environment. Existing organizations use the upgrade or key-management flow above. Portal access instructions are returned in `data.portal_access`. **Newly minted partner keys always carry the `tb_prt_` prefix.** The older `trb_partner_` prefix is still *accepted* on existing keys but is no longer *issued*, so a key you mint today will never look like it. Do not hard-code, pattern-match, or validate against any prefix: prefixes are presentation metadata only, and authorization is decided exclusively by the scopes attached to the key (`services/platformApiAuthResolver.js`). All authenticated endpoints expect: ``` Authorization: Bearer tb_prt_xxxxxxxxxxxx ``` ## 2. Make your first call Verify auth and pull partner info: ```bash curl -s "https://api.trillboards.com/v1/partner/info" \ -H "Authorization: Bearer tb_prt_xxxxxxxxxxxx" ``` Success response: ```json { "success": true, "data": { "partner_id": "65f1c...", "name": "Acme Vending", "tier": "standard", "device_count": 42, "created_at": "2026-01-15T09:30:00Z" } } ``` ## 3. Handle errors Every 4xx/5xx follows the canonical envelope and echoes a request ID you can share with support: ```json { "error": { "type": "invalid_request_error", "code": "screen_not_found", "message": "Screen not found or access denied", "param": "screen_id", "request_id": "req_a1b2c3d4e5f6789012345678901234ab", "doc_url": "https://api.trillboards.com/docs/errors#screen_not_found", "help": { "summary": "Screen IDs are prefixed 'scr_'. List your screens at GET /v1/partner/devices.", "portal_url": "https://trillboards.com/earner/screens" } } } ``` ## 4. Common error codes All codes below come from `utils/partnerApiErrors.ts` (see the error reference for the full catalog): > `invalid_api_key_format` is defined in the error catalog but **no auth path ever emits it** — the > server performs no prefix or format validation. A malformed or unknown token returns `invalid_api_key`. | `error.code` | HTTP | When you see it | |---|---|---| | `authentication_required` | 401 | No `Authorization` header. Add `Bearer tb_prt_...`. | | `invalid_api_key` | 401 | Token does not resolve to an active partner — rotate via `POST /v1/partner/api-key/rotate`. | | `partner_inactive` | 403 | Account suspended. Contact support. | | `screen_not_found` | 404 | `error.param` identifies the bad ID. Fetch the list from the portal or the devices endpoint. | | `rate_limit_exceeded` | 429 | Honor the `Retry-After` header. The enforced authenticated cap is 1000 req/min per API key, flat across every data-access tier. | | `tier_insufficient` | 403 | Endpoint requires a higher data-access tier; `error.help.current_tier` and `required_tier` tell you exactly what to upgrade to. | ## 5. Next common operations - **List devices:** `GET /v1/partner/devices` - **Register a device:** `POST /v1/partner/device` with `{ "name": "...", "venue_type": "..." }` - **Record an impression batch:** `POST /v1/partner/impressions/batch` (for offline-sync SDKs) - **Create a webhook:** `POST /v1/partner/webhooks` with `{ "url", "events" }` — payloads are HMAC-signed - **Get screen earnings:** `GET /v1/partner/connect/earnings?range=7d` ## 6. Optimize AI creatives on real screens Follow the [Creative AI Feedback Loop](https://api.trillboards.com/docs/guides/integrations/creative-feedback) to fund inventory, generate or import images, review and serve variants, and use measured attention and dwell to iterate your model through REST, MCP or the TypeScript SDK. Install `@trillboards/api-client@3.2.1` for typed funding, native MCP buying, creative experiments, historical queries and the live audience tail. The walkthrough includes stable purchase keys, Stripe balance readback and correction-aware reward scans. Purchased HUD packages run locally without company credentials and then use this same account for paid live delivery. ## Next steps - Full reference: [`/docs/partner`](https://api.trillboards.com/docs/partner) - Error catalog: [`/docs/errors`](https://api.trillboards.com/docs/errors) - Partner API client: `npm install @trillboards/api-client` - Screen playback SDK: `npm install @trillboards/ads-sdk` --- # CTV SDK Quickstart # Source: https://api.trillboards.com/docs/guides/quickstart/sdk.md # SDK Quickstart The SDK API (`/v2/sdk`) powers CTV and DOOH measurement: census-based audience enrichment, QR scan attribution, and impression ingestion with GIVT dedup and frequency counting. Use it from a device SDK, a server-side measurement pipeline, or a pixel proxy. **Base URL:** `https://api.trillboards.com` ## 1. Get an API key SDK keys have the prefix `tb_dat_` (issued via the [portal](https://trillboards.com/developers)). They must carry SDK scopes such as `sdk:enrich`, `sdk:qr`, and `sdk:impression`. Pass the key in the `X-API-Key` header on every request. ## 2. Enrich an impression context Census enrichment returns a stable household ID, IAB demographic segments, and connection-type hints for the caller's IP. ```bash curl -s -X POST "https://api.trillboards.com/v2/sdk/enrich" \ -H "X-API-Key: tb_dat_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{}' ``` Success response: ```json { "geohash6": "dr5ruq", "iabIncomeSegmentId": "1003", "iabIncomeSegmentName": "$75,000-$99,999", "iabAgeSegmentId": "2004", "iabAgeSegmentName": "35-44", "educationTier": "bachelors_plus", "connectionType": "residential", "householdId": "a8c3f1d7b2e4...", "source": "maxmind" } ``` `source` names the geolocation database behind the record: `maxmind` (MaxMind GeoLite2), `dbip` ([IP Geolocation by DB-IP](https://db-ip.com), CC BY 4.0), or `ipinfo` when neither is loaded. Pass an optional `geohash6` (6-character geohash) in the body when the client knows its location more precisely than the caller IP suggests. ## 3. Handle errors Authentication, validation, and rate-limit failures all return the canonical envelope: ```json { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Please retry after the specified time.", "request_id": "req_a1b2c3d4e5f6789012345678901234ab", "doc_url": "https://api.trillboards.com/docs/errors#rate_limit_exceeded" } } ``` 429 responses also include the standard `Retry-After` header. The enrich endpoint is capped at 300 req/min per key; QR beacon at 200 req/min; impression at 1000 req/min. Back off on the client side — don't stack retries. ## 4. Ingest impressions Once enriched, post impressions to `/v2/sdk/impression` with a client-generated `impressionId` (UUID) so GIVT dedup can latch across retries. Required fields include `adId`, `placementId`, and `channel` (`ctv`, `dooh`, or `web`). See `validation/sdkSchemas.js` or the generated OpenAPI for the full body shape. ## Next steps - Reference: [`/docs/sdk`](https://api.trillboards.com/docs/sdk) - QR attribution flow: `POST /v2/sdk/qr/scan` then `POST /v2/sdk/qr/beacon` - Error catalog: [`/docs/errors`](https://api.trillboards.com/docs/errors) --- # OpenRTB / DSP Quickstart # Source: https://api.trillboards.com/docs/guides/quickstart/openrtb.md # OpenRTB / DSP Quickstart The `/openrtb/v2` surface is the authenticated buyer/control plane: discover inventory, forecast, manage deals and creatives, report, and submit OpenRTB 2.6 bid responses. It uses the same centralized platform API key as MCP and reporting. **Base URL:** `https://api.trillboards.com/openrtb/v2` ## 1. Mint and link one API key Existing partner owners/admins self-mint at under Partner API. Headless agents can use the existing autonomous registration route instead: ```bash curl -sS -X POST 'https://api.trillboards.com/v1/partner/agent/register' \ -H 'Content-Type: application/json' \ -d '{ "agent_type": "dsp_agent", "name": "Example Buyer Agent", "email": "programmatic@example.com", "company": "Example Buyer" }' ``` The `201` response returns `api_key` once and the exact MCP endpoint `https://api.trillboards.com/mcp`. Registration is limited to five attempts per IP per hour. Store the key in a secrets manager and use the canonical header everywhere: ```http Authorization: Bearer ``` Link that same key to one DSP seat; this grants the DSP capabilities without minting a second credential: ```bash curl -sS -X POST 'https://api.trillboards.com/openrtb/v2/onboard' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "company_name": "Example Buyer", "seat_id": "example_buyer", "contact_email": "programmatic@example.com" }' ``` The response includes `api_key.reused_platform_key: true` and does not echo the secret. Anonymous onboarding still mints a `tb_dsp_*` key only for backwards compatibility. Existing `x-api-key`, `tb_dsp_*`, `tb_prt_*`, and migrated `trb_partner_*` clients continue to work; capabilities, not prefixes, decide access. ## 2. Discover inventory ```bash curl -sS 'https://api.trillboards.com/openrtb/v2/adslots?format=openrtb&country=US&limit=20' \ -H 'Authorization: Bearer ' ``` `format=openrtb` returns ready-to-use `bid_requests` with request and impression IDs. Omit it for the planning-oriented screen summary. Use `/forecast`, `/discover`, `/screens/availability`, and `/deals` with the same header for planning. ## 3. Submit a bid response `POST /openrtb/v2/bid` accepts an OpenRTB **bid response**, not a supply-side bid request. Match `id` and `impid` to inventory/request IDs received from the buyer flow. ```bash curl -sS -X POST 'https://api.trillboards.com/openrtb/v2/bid' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "id": "request_from_adslots", "seatbid": [{ "seat": "example_buyer", "bid": [{ "id": "bid_001", "impid": "imp_65a1b2c3d4e5f6a7b8c9d0e1", "price": 4.00, "adm": "...", "crid": "creative_001", "adomain": ["example.com"] }] }] }' ``` New seats start in sandbox. Sandbox status prevents auction injection, but it does not bypass scopes. | HTTP | Meaning | |---|---| | `200` | One or more bids passed validation and were accepted. | | `204` | No accepted bids; treat as a normal no-bid outcome. | | `400` | Malformed bid response or missing required field. | | `401` | Missing, invalid, revoked, or unlinked platform key. | | `403` | The key lacks the route capability. | | `415` | `Content-Type` is not `application/json`. | ## Raw exchange bidstream is different A bilateral supply connection sends OpenRTB bid requests to its configured endpoint: ```text POST https://api.trillboards.com/openrtb/bidder//bid ``` That high-QPS raw bidstream does **not** send the platform API key on every request. Trust comes from the provisioned exchange connection and alias, optional IP allowlist/mTLS/signature, QPS limits, and callback telemetry. Win, impression, and loss use the connection-specific callback URLs returned in each bid. ## Common errors | `error.code` | HTTP | How to fix | |---|---:|---| | `authentication_required` | 401 | Add `Authorization: Bearer `. | | `invalid_api_key` | 401 | Re-check or rotate the self-minted key in the portal. | | `missing_scope` | 403 | Complete DSP onboarding for that same key or request the missing entitlement. | | `invalid_json` | 400 | Send valid JSON with `Content-Type: application/json`. | | `rate_limit_exceeded` | 429 | Honor `Retry-After`; do not retry 204 no-bids. | ## Next steps - Full DSP reference: - Machine-readable OpenAPI: - MCP endpoint: - AI-readable contract: - Error catalog: --- # Data API Quickstart # Source: https://api.trillboards.com/docs/guides/quickstart/data-api.md # Data API Quickstart The Data API gives agencies, DSPs, and data partners read access to Trillboards audience intelligence: live audience snapshots, historical aggregations, semantic search over scene embeddings, and lookalike screen discovery. Use it for planning, attribution, and measurement workflows that do not need ad-serving control. **Base URL:** `https://api.trillboards.com` ## 1. Get an API key Data API keys have the prefix `tb_dat_` and are issued from the [portal](https://trillboards.com/developers). Keys are scoped; common scopes are `read:live`, `read:historical`, `read:semantic`, and `stream:live`. Each request must include the key in the `X-API-Key` header. ## 2. Make your first call List the screens your key is authorized to see: ```bash curl -s "https://api.trillboards.com/v1/data/screens?limit=5" \ -H "X-API-Key: tb_dat_xxxxxxxxxxxx" ``` Success response: ```json { "screens": [ { "screen_id": "...", "mongo_screen_id": "...", "name": "Lobby #3", "venue_type": "fitness", "city": "Brooklyn", "country": "US", "status": "active" } ], "count": 1, "offset": 0, "limit": 5 } ``` If the `X-API-Key` header is missing or invalid, you get the canonical error envelope: ```json { "error": { "type": "authentication_error", "code": "invalid_api_key", "message": "Invalid or inactive API key provided.", "request_id": "req_a1b2c3d4e5f6789012345678901234ab", "doc_url": "https://api.trillboards.com/docs/errors#invalid_api_key" } } ``` Every 4xx/5xx response echoes `error.request_id` matching the `X-Request-Id` response header. Paste it into a support ticket and we can trace the request end-to-end. ## 3. Common errors | `error.code` | HTTP | How to fix | |---|---|---| | `authentication_required` | 401 | Add the `X-API-Key` header. | | `invalid_api_key` | 401 | Check the key you copied from the portal. Rotate if compromised. | | `insufficient_permissions` | 403 | Your key is missing a required scope (for example, `read:historical`). Request an upgrade from your account manager. | | `rate_limit_exceeded` | 429 | Back off using the `Retry-After` header; default limit is 60 req/min per key. | ## Next steps - Full reference: [`/docs/data`](https://api.trillboards.com/docs/data) - Error catalog: [`/docs/errors`](https://api.trillboards.com/docs/errors) - FEIN query DSL: `POST /v1/data/query` for composable dimensions + measures - Streaming: `GET /v1/data/stream` (Server-Sent Events, scope `stream:live`) --- # RL Environment Trials # Source: https://api.trillboards.com/docs/guides/integrations/rl-environments.md # Try Trillboards RL environments Download a free scored trial and test your agent against the same grading core used in its full package. Each trial includes two tuning tasks, the evidence needed to attempt them, a reference workflow and a local grader. Choose the decision you want your model to improve: | Trial | What your agent does | Full package | |---|---|---| | Inventory Planning | Forecast inventory and build an allocation schedule. | https://datavendor.ai/listings/75707605-cbea-43ca-ba7f-4c2299cb3fbe | | Creative Attention | Allocate creatives and forecast measured attention. | https://datavendor.ai/listings/e6d9f988-4224-403f-a786-3652123b1367 | | Ad Serving Debugging | Diagnose serving failures from native evidence. | https://datavendor.ai/listings/ada56f81-08f5-4754-9584-50bdb5423b10 | | Scene Search | Retrieve scenes and check caption or provenance evidence. | https://datavendor.ai/listings/225f6b8f-7543-4152-8a58-dd6ec46447d9 | | Creative Policy Fit | Route creatives and explain owner-policy compatibility. | https://datavendor.ai/listings/e4950087-ad62-473f-9f76-fbc2aa25714f | | Campaign Operator | Activate and pause a campaign through native services. | https://datavendor.ai/listings/702c01df-7005-428f-a160-ea129efc6dd8 | | Creative Response | Forecast measured response by context and reconcile native play evidence. | https://datavendor.ai/listings/c1917228-bd5a-44c6-a09d-acf7eefe8a05 | | Fleet Diagnosis | Diagnose incidents and find supporting heartbeat evidence. | https://datavendor.ai/listings/d1cb1a18-4305-431f-8fe1-9db48eac39f0 | | Finance Operations | Audit invoice basis, current allocation admission and historical allocations. | https://datavendor.ai/listings/1915d698-4235-4b63-82e1-55e458c98942 | | Auction Calibration | Attribute settled plays and forecast measured settled CPM. | https://datavendor.ai/listings/87b4a3d4-96c5-4592-aca1-7beacb1276b0 | | Camera Alert Operations | Replay native routing state and reconcile notification receipts. | https://datavendor.ai/listings/60ffc1df-8354-4e82-adff-11847a73000e | The machine-readable catalog supplies each product's download URL, ZIP byte count and SHA-256, task IDs, and embedded manifest SHA-256: https://api.trillboards.com/docs/integrations/rl-environments.json ## Download and run a recorded trial Use Python 3.11 or 3.12. Copy the download URL and SHA-256 from your chosen catalog entry, then extract into a new directory. Reference runs use the included evidence and require no Trillboards account or model-provider key. ~~~sh DOWNLOAD_URL='COPY_DOWNLOAD_URL_FROM_CATALOG' SHA256='COPY_SHA256_FROM_THE_SAME_ENTRY' curl --fail --location "$DOWNLOAD_URL" --output trial.zip python3 - "$SHA256" <<'PY' import hashlib, sys with open("trial.zip", "rb") as archive: actual = hashlib.file_digest(archive, "sha256").hexdigest() if actual != sys.argv[1]: raise SystemExit("ZIP SHA-256 mismatch") PY mkdir trial unzip -q trial.zip -d trial cd trial python3 -S -B run.py --verify python3 -S -B run.py --list python3 -S -B run.py --receipt ../reference.json ~~~ The receipt contains each task's answer and numeric score. A reference is a reproducible comparison point; scores can be below 1. The recorded trials use recorded evidence, with zero production actions and zero model calls in the reference run. To score your own answer, select a task returned by the list command. Read its public case and place your answer JSON outside the extracted package: ~~~sh TASK_ID='COPY_A_TASK_ID_FROM_THE_LIST' python3 -S -B actor/evidence.py case "$TASK_ID" python3 -S -B run.py --task "$TASK_ID" \ --answer ../answer.json --receipt ../answer-score.json ~~~ For an agent evaluation, give the agent the included actor package and public task. Keep the grader and reference answers on your grading host. The trial README describes its evidence and media profile; the full package's listing describes the purchased archive. ## Optional HUD model evaluation The recorded trials also include a HUD runner with actor and verifier isolation. Install its pinned requirements into a separate environment: ~~~sh python3 -m venv ../hud-venv . ../hud-venv/bin/activate export PYTHONDONTWRITEBYTECODE=1 python -m pip install --no-compile -r requirements.lock python hud-run.py --check python hud-run.py --receipt ../hud-reference.json ~~~ With no model option, the runner uses the included reference agent and makes no inference calls. To evaluate a model, configure your own provider key outside the trial and select a supported provider and model: ~~~sh python hud-run.py --provider openai --model YOUR_MODEL_ID \ --receipt ../model-score.json ~~~ Your model provider bills inference. Use the package's included guide for its optional Prime adapter. ## Run the Campaign Operator trial Campaign Operator uses Linux x86-64, Docker Engine and its CLI, at least 8 GiB available RAM and 10 GB free disk. The initial build downloads public dependencies and service images. Its two reference tasks require no Trillboards account, model key or Stripe key. Start from a fresh extraction of the Campaign Operator ZIP and verify it using the catalog's ZIP SHA-256 as above: ~~~sh python3 -S -B run.py --verify python3 -S -B run.py --manifest python3 -S -B run.py --build python3 -S -B run.py --baseline --task operator-activate-01 \ --receipt ../activation.json python3 -S -B run.py --baseline --task operator-pause-01 \ --receipt ../pause.json ~~~ Each episode creates isolated PostgreSQL, Redis and ClickHouse services, then grades the resulting native campaign, billing, moderation and serving state. The reference workflow cleans up its owned services. These trial actions run inside that environment; they do not book production media or charge a card. To connect your own agent, follow the included SDK/MCP, HUD or Prime instructions. The agent receives the filtered native campaign tools and public task. The independent verifier reads the final state to assign its score. ## Move from the trial to the full package Open the matching full-package link above for its complete task set, held-out evaluations, evidence, provenance and benchmark reports. DataVendor requires buyer-account approval before checkout; the free trials above run without that approval. Preserve the archive and manifest checks in your evaluation pipeline. The workflow bundle contains four existing products; its member tasks overlap those individual packages. ## Connect to live Trillboards inventory Use the existing API client: ~~~sh npm install @trillboards/api-client@3.2.1 ~~~ Create your account and API access, discover inventory, submit reviewed creatives and follow the live workflow in the partner quickstart: https://api.trillboards.com/docs/guides/quickstart/partner For the creative feedback loop, live signals and integration contract: https://api.trillboards.com/docs/guides/integrations/creative-feedback Production serving follows native availability, CPM, screen-owner preferences, moderation and funding checks. Funded media spend and API/data usage are separate charges. The downloadable environment purchase does not fund production media. Machine-readable API discovery: https://api.trillboards.com/llms.txt https://api.trillboards.com/api/discover --- # Creative AI Feedback Loop # Source: https://api.trillboards.com/docs/guides/integrations/creative-feedback.md # Creative AI Feedback Loop Connect your creative model to real advertising screens. Generate or import images, choose what runs, and feed measured attention and dwell into the next decision. This guide covers the live creative optimization workflow through REST, MCP and the TypeScript SDK. **Base URL:** `https://api.trillboards.com` ## Get your buying key Use the existing [Partner API onboarding](https://api.trillboards.com/docs/guides/quickstart/partner) to create your account and key. Your key identifies the same buying account throughout inventory discovery, funding, creative review, serving and reward reads. ## Live workflow ### Fund a media buy Discover products with `get_products` and inventory with `discover_inventory` on the [MCP endpoint](https://api.trillboards.com/mcp). Complete the existing `create_media_buy` flow and retain its `media_buy_id` and selected `package_id`. The experiment binds to that package's actual funded advertisement and placements. Use a production buying account to serve; a sandbox buy exercises validation without creating live placements. Media and API usage have separate payment decisions. The media buy uses its accepted inventory, CPM and contracted quote; activation requires the native funding receipt. Generation, image import and each reward page use the existing `data_api/api_calls` tariff, free quota and API allowance. Media credit alone does not authorize an unfunded API read. Check current rates at [pricing](https://api.trillboards.com/v1/partner/pricing); metered responses include `usage_receipt` with exact units and charges. Stripe-backed account funding follows the existing verified payment flow; posting a creative does not fund a placement. API holds use your current graduated tariff. A lone request that crosses the free allowance reserves only its paid suffix. Concurrent admitted work can rebalance active holds; commitment, cancellation and expiry return excess reserves. Your final `usage_receipt` reports the exact actual charge. Repeating an operation key retains its original identity and tariff. Use a partner buying key with `campaigns:write`, `bid:write` or `partner:write` for writes, and `reports:read`, `stats:read` or `partner:read` for reads. MCP additionally requires `mcp:tools`. ### Fund and book with the SDK Install `@trillboards/api-client@3.2.1`. The SDK uses the native MCP protocol for media buying and REST for account funding and creative experiments. ```typescript import { TrillboardsClient } from '@trillboards/api-client'; const client = new TrillboardsClient({ apiKey: process.env.TRILLBOARDS_API_KEY! }); // Persist purchaseKey before calling; reuse it only for this exact purchase. async function openCreditCheckout(purchaseKey: string) { const checkout = await client.billing.createCreditCheckout( { tier: 'tier_500' }, { idempotencyKey: purchaseKey }, ); return checkout.checkout_url; } async function readFunding() { const usage = await client.billing.getUsageSummary(); return { balanceCents: usage.credit_balance_cents, reservedCents: usage.credit_reserved_cents, }; } async function bookInventory(input: { productId: string; budgetUsd: number; brandDomain: string; startTime: string; endTime: string; operationKey: string; }) { try { const accounts = await client.mediaBuys.listAccounts(); const account = accounts.accounts.find(value => value.status === 'active'); if (!account) throw new Error('Use an active production buying account.'); const catalog = await client.mediaBuys.getProducts({ pagination: { max_results: 100 }, }); const product = catalog.products.find(value => value.product_id === input.productId); const fixed = product?.pricing_options.find(value => value.fixed_price !== undefined); if (!product || !fixed) throw new Error('Select a current product and fixed CPM option from the catalog.'); const buy = await client.mediaBuys.create({ idempotency_key: input.operationKey, account: { account_id: account.account_id }, brand: { domain: input.brandDomain }, start_time: input.startTime, end_time: input.endTime, packages: [{ product_id: product.product_id, pricing_option_id: fixed.pricing_option_id, budget: input.budgetUsd }], }); if (!buy.media_buy_id || !buy.packages?.length) throw new Error('Buy did not return its native identifiers.'); return buy; } finally { await client.mediaBuys.close(); } } ``` Open the returned Checkout URL and complete payment. Read `credit_balance_cents` and `credit_reserved_cents` after the verified Stripe webhook commits the credit; a browser redirect or Checkout-session creation alone does not fund the account. These values and usage amounts are exact decimal strings in cents. `billing.setup({payment_method_id})` saves a payment method; `billing.purchaseCredits({tier}, {idempotencyKey})` uses an already configured payment method. Both credit-purchase paths require a stable `Idempotency-Key`. Direct purchases return `status`, `requires_action` and, when Stripe needs confirmation, `client_secret`; complete that Stripe action before treating credits as settled. Call `mediaBuys.getProducts` to choose the product and pricing-option identifiers, following `pagination.cursor` when `pagination.has_more` is true. Supply your budget, brand domain and future ISO8601 flight bounds to `bookInventory`. Retain `media_buy_id`, `packages[].package_id` and the returned funding state. The native buy quote, funding receipt, creative eligibility and screen-owner preferences determine serving readiness. `status` at the MCP task layer describes task execution. Once the package is funded, use those exact identifiers in the experiment below; creative review and registration complete its native serving workflow. ### Create, review and register an action Create the experiment after funding. Policy versions are exact decimal strings. Reuse the enrollment key only for the same creation request. ```bash curl -sS -X POST https://api.trillboards.com/v1/partner/creative-experiments \ -H "Authorization: Bearer $TRILLBOARDS_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Creative attention loop","media_buy_id":"YOUR_MEDIA_BUY_ID","package_id":"YOUR_PACKAGE_ID","objective":"gaze_share","idempotency_key":"enroll-attention-v1","expected_policy_version":"0"}' ``` Keep the response's `data.id` and `data.policy_version`. Configure `/brand` using your `brand_name`, complete `brand_rules`, optional `brand_assets` and `stance`. The brand-rules object contains `brand_name`, `palette` (`primary`, `secondary`, `accents`), `typography` (`headline`, `body`), `voice_tone`, `mandatory_elements`, `prohibited_elements`, `products`, `taglines` and `stance_preferences`; see the [Partner reference](https://api.trillboards.com/docs/partner) for the full schema. A complete brand body for `/brand`: ```json { "brand_name": "Acme", "brand_rules": { "brand_name": "Acme", "palette": {"primary": "#111111", "secondary": "#FFFFFF", "accents": []}, "typography": {"headline": "Bold sans serif", "body": "Sans serif"}, "voice_tone": ["clear"], "mandatory_elements": ["Brand name", "Clear call to action"], "prohibited_elements": [], "products": [], "taglines": [], "stance_preferences": ["intent_capture"] }, "brand_assets": [], "stance": "intent_capture" } ``` Then choose an image source: - Call `/creatives/generate` with `stance`, `aspectRatio`, `brief` and a fresh `idempotency_key` to use native generation. - Call `/creatives/import` with a fresh key and exactly one of `asset_url`, `source_creative_id` or `image_base64`. Inline bytes also require `content_type`. PNG, JPEG and WebP are validated from their actual bytes; an existing source creative must belong to this partner. Both paths return a staged `creativeId` and `cdnUrl`. If a response is lost, reuse its operation key: the committed 409 response retains the original receipt. Get the existing experiment to recover your native `creatives` library, which returns the latest 500 candidates and marks `creatives_truncated` when more exist. Each row retains its original `advertisement_mongo_id` and reports `linked_to_experiment_ad` for this experiment's current ad; the committed receipt also retains its server-derived `creativeId` and `renderKey`. This recovery does not repeat paid generation/import. `moderation_status` and `content_sha256` stay NULL until the review matches that exact asset URL. Preview the image and call `/creatives/review` with `{"creative_id":"YOUR_CREATIVE_ID","decision":"approve"}` or `"reject"`. Brand approval and AI moderation are separate steps. Registration succeeds once the creative is approved, linked to this advertisement and eligible; `CREATIVE_NOT_READY` means it has not reached that state. Register the approved action with the latest version: ```json { "expected_version": "1", "variants": [ {"variant_id": "headline-a", "creative_id": "YOUR_CREATIVE_ID", "model_version": "your-model-v3"} ] } ``` Post that body to `/v1/partner/creative-experiments/YOUR_EXPERIMENT_ID/variants`, using the actual current version from the experiment response. Registration returns a new `policy_version`; retain it. Variant, model and byte identity stay fixed after registration. A new model/image gets a new variant identity. ### Start serving and iterate Put a weight generation to `/v1/partner/creative-experiments/YOUR_EXPERIMENT_ID/policy`: ```json { "expected_version": "2", "weights": [{"creative_id": "YOUR_CREATIVE_ID", "weight": 1}] } ``` Use the registration response's version. Weights must be between zero and one, with at least one positive entry; zero removes that creative from selection. Every update returns the next version. On `CREATIVE_POLICY_CONFLICT`, get the experiment and reconcile the current version before writing again. The next signed paid play uses the current eligible policy. Screen preferences and current approval are checked at selection. Each resulting reward retains the issued variant, model version, policy version, byte hash, render specification and selection probability. You can update the next play's policy as soon as your model makes a decision. Measured rewards follow the existing five-minute refresh and daily 48-hour correction sweep. ### Read rewards and keep corrections ```bash curl -sS -G "https://api.trillboards.com/v1/partner/creative-experiments/$EXPERIMENT_ID/rewards" \ -H "Authorization: Bearer $TRILLBOARDS_API_KEY" \ --data-urlencode "from=$FROM_DATE" --data-urlencode "to=$TO_DATE" \ --data-urlencode 'limit=500' --data-urlencode "idempotency_key=$READ_OPERATION_KEY" ``` Dates are inclusive UTC calendar dates, up to 31 days per scope; pages contain at most 5,000 rows. Use a fresh operation key for each page and each new poll. A repeated pending or committed key returns HTTP 409 (`OPERATION_IN_PROGRESS` or `OPERATION_ALREADY_COMMITTED`); a committed response includes the original receipt at `error.help.usage_receipt`. Follow `next_cursor` with the same dates. When `scan_complete` is true, the next poll starts with no cursor. This reads latest measured states and recovers late writes on the next complete rescan. Store rows by `canonical_key` and deduplicate by `reward_revision_id`: an `upsert` replaces that play's contribution; a `retract` removes it. Keep the revision so a repeated tombstone has no second effect. `build_stamp` and `builder_version` record provenance; a rebuild can retain the same logical revision. `gaze_share` uses `gaze_pct_on_screen` on a 0–1 scale; `gaze_overlap_ms` uses milliseconds; `dwell_ms` uses `dwell_capped_ms_mean`. NULL means unmeasured. Parse `creative_feedback` for the immutable issued action and model metadata. Train/evaluate against actual served observations; an unserved candidate has no measured reward. Subscribe to `creative.rewards_ready` through existing partner webhooks or SSE. Hints are free and carry an experiment, date range, build provenance and reward-reader URL. Fetching each reward page uses the API tariff. On reconnect, recover with a full bounded rescan and logical deduplication. The hint is a wake-up signal; your stored canonical keys and revisions own recovery. ### Complete operation map All paths below use `/v1/partner/creative-experiments`; `{id}` is the returned experiment ID. REST responses use `{success:true,data:...}`. MCP tools use the same schemas and native account. | REST | MCP tool | Result / usage | |---|---|---| | POST base | `create_creative_experiment` | Funded enrollment; free | | GET `/{id}` | `get_creative_experiment` | Current experiment/version; free | | POST `/{id}/brand` | `configure_feedback_brand` | Native brand/reactive configuration; free | | POST `/{id}/creatives/generate` | `generate_brand_creative` | Staged generated image; metered | | POST `/{id}/creatives/import` | `import_feedback_creative` | Staged imported image; metered | | POST `/{id}/creatives/review` | `review_feedback_creative` | Brand approve/reject; free | | POST `/{id}/variants` | `register_creative_variants` | Approved immutable actions; free | | PUT `/{id}/policy` | `update_creative_policy` | Versioned weights/start; free | | GET `/{id}/rewards` | `get_creative_rewards` | Latest-state reward page/receipt; metered | | POST `/{id}/stop` | `stop_creative_experiment` | Stop using `expected_version`; free | Use the REST examples above or these MCP tools to start optimizing creatives. Both transports accept the same request bodies and return the same typed results. Historical HUD tasksets use the existing verified product export, immutable vintage, manifest and dictionary. Their actor sees earlier measured observations; their verifier grades against later actual plays. The live HUD adapter calls these same MCP operations. See the repository's `integrations/hud/creative-feedback/README.md` for packaging and runtime commands. ## TypeScript SDK Install the published client: ```bash npm install @trillboards/api-client@3.2.1 ``` The same operations are available on `client.creativeExperiments`. These functions use your funded media-buy identifiers, actual brand configuration and model-generated assets. Persist the experiment ID and each returned policy version. Call `approveAndServe` after inspecting the returned `cdnUrl` and completing AI moderation; `CREATIVE_NOT_READY` means that candidate is not eligible yet. ```typescript import { TrillboardsClient } from '@trillboards/api-client'; import { randomUUID } from 'node:crypto'; const apiKey = process.env.TRILLBOARDS_API_KEY; if (!apiKey) throw new Error('Set TRILLBOARDS_API_KEY to your partner buying key.'); const client = new TrillboardsClient({ apiKey }); type BrandConfiguration = Parameters[1]; async function createLoop( mediaBuyId: string, packageId: string, brand: BrandConfiguration, operationKey: string, ) { const experiment = await client.creativeExperiments.create({ name: 'Creative attention loop', media_buy_id: mediaBuyId, package_id: packageId, objective: 'gaze_share', idempotency_key: operationKey, expected_policy_version: '0', }); const id = experiment.data.id; await client.creativeExperiments.configureBrand(id, brand); return id; } async function stageAction( id: string, brief: string, operationKey: string, assetUrl?: string, ) { const creative = assetUrl ? await client.creativeExperiments.importCreative(id, { asset_url: assetUrl, idempotency_key: operationKey, }) : await client.creativeExperiments.generate(id, { brief, stance: 'intent_capture', aspectRatio: '16:9', idempotency_key: operationKey, }); return creative.data; // creativeId, cdnUrl and the usage_receipt } async function approveAndServe( id: string, creativeId: string, variantId: string, modelVersion: string, ) { await client.creativeExperiments.review(id, { creative_id: creativeId, decision: 'approve', }); const current = await client.creativeExperiments.get(id); const registered = await client.creativeExperiments.registerVariants(id, { expected_version: current.data.policy_version, variants: [{ variant_id: variantId, creative_id: creativeId, model_version: modelVersion, }], }); return client.creativeExperiments.updatePolicy(id, { expected_version: registered.data.policy_version, weights: [{ creative_id: creativeId, weight: 1 }], }); } async function* readRewardPages( id: string, from: string, to: string, ) { const scanId = randomUUID(); let cursor: string | undefined; let page = 0; do { const result = await client.creativeExperiments.getRewards(id, { from, to, limit: 500, idempotency_key: `${scanId}:page:${page}`, ...(cursor ? { cursor } : {}), }); yield result.data; if (result.data.scan_complete) return; cursor = result.data.next_cursor ?? undefined; page += 1; if (!cursor) throw new Error('Incomplete reward scan returned no next cursor.'); } while (cursor); } async function stopLoop(id: string) { const current = await client.creativeExperiments.get(id); return client.creativeExperiments.stop(id, { expected_version: current.data.policy_version, }); } ``` Generate and persist a unique operation key (for example, `crypto.randomUUID()`) before each new creation, generation or import. Pass the same key only for the same operation and inputs. `readRewardPages` creates a fresh `scanId` for every invocation; its pages derive distinct billing keys. Create the loop once, then stage and review each new model candidate against that experiment. A full reward-page scan feeds your next decision; apply each row's upsert or retraction using the keys above. Start the next poll with no cursor so corrections remain visible. For generation/import, keep the original operation key after a lost response: retrying it recovers the committed receipt and creative identity without repeating generation/import. For reward reads, the committed receipt contains no reward rows or cursor. Abandon an interrupted scan and invoke `readRewardPages` again from the beginning; it creates fresh page keys. Each recovery page follows the normal API tariff. Apply its latest states using the same canonical-key and revision deduplication. ## Inventory planning and ad-serving debugging environments The Inventory Planning package combines measured screen-hour history with a dated native catalog and availability snapshot. Its HUD tasks forecast gaze share, dwell milliseconds and face density, then grade budget-constrained plans against that frozen snapshot and separate recorded reference windows. The downloadable package contains the source Parquets, dictionary, exact units, task splits, baseline scores and separate numerical verifier. The Ad-serving Debugging package follows actual issued request and impression identifiers through request, auction, render and completion evidence. Tasks diagnose the observed stage or evidence gap and reconcile canonical billable plays and recorded USD amounts. Native billing-key and eligibility projections retain the existing billing policy. Both version-3 downloads include a portable Python project and HUD0.6.20. From the extracted directory: ```bash python3.12 -m venv ../trillboards-venv . ../trillboards-venv/bin/activate python -m pip install -r requirements.lock export PYTHONDONTWRITEBYTECODE=1 python -m trillboards_hud.product_package verify . python -m trillboards_hud.product_runner . --split evaluation --receipt ../evaluation.json ``` The baseline needs no account or model key. Add `--provider openai --model YOUR_MODEL` with your provider credential to evaluate a model through the same tools and verifier. Put execution receipts beside the immutable download. Live inventory discovery and delivery use `client.mediaBuys`; creative outcomes use `client.creativeExperiments.getRewards`. General screen history and the separate live audience tail are exposed through `client.data`, with their endpoint scopes `read:historical`, `read:live` and `stream:live`. The audience stream is a live tail; use bounded history reads after reconnect. Creative reward recovery uses the canonical-key/revision protocol above. ## References - [Complete Partner API reference](https://api.trillboards.com/docs/partner) - [Current API pricing](https://api.trillboards.com/v1/partner/pricing) - [Error reference](https://api.trillboards.com/docs/errors) --- # Webhook signature verification # Source: https://api.trillboards.com/docs/guides/integrations/webhook-signature-verification.md # Webhook signature verification Canonical contract for verifying Trillboards-emitted webhook deliveries. Single source of truth — partner-api.yaml, dsp-api.yaml, the `@trillboards/api-client` SDK, and the `/api/discover.globals.webhook_contract` runtime manifest all derive from this document. ## TL;DR Every outbound webhook from `api.trillboards.com` carries: ``` X-Trillboards-Event: # e.g. "impression.recorded" X-Trillboards-Timestamp: # mirrored from `t=` for redundancy X-Trillboards-Signature: t=,v0=,v1= X-Trillboards-Webhook-Id: # Partner surface only X-Trillboards-Delivery-Id: del_ # DSP surface (Partner adds in PR-D-ish) User-Agent: Trillboards-Webhook/1.0 # Partner Trillboards-DSP-API/1.0 # DSP ``` Where: - `v1 = HMAC-SHA256(secret, "${timestamp}.${payload}")` — **canonical**, replay-protected - `v0 = HMAC-SHA256(secret, payload)` — legacy body-only digest emitted during the migration window for partners on pre-`@trillboards/api-client@0.3.0` verifiers or who built their own body-only verifier from earlier dsp-api.yaml examples A verifier MUST: 1. Parse the compound `X-Trillboards-Signature` header. 2. Extract `t=`; reject if `|now - ts| > tolerance` (default 300 seconds, Stripe-aligned). 3. Compute `expected_v1 = HMAC-SHA256(secret, "${t}.${payload}")` and compare against `v1=` from the header using a constant-time string comparison. 4. If `v1=` is absent (server in pre-migration state), fall back to verifying `v0=` against `HMAC-SHA256(secret, payload)`. 5. If the header is the legacy `sha256=` form, try both `v1` and `v0` candidate inputs. 6. Reject with HTTP 401 on any mismatch. ## Recommended: `@trillboards/api-client@>=0.3.0` ```ts import { Webhooks } from '@trillboards/api-client'; app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => { try { const event = Webhooks.constructEvent( req.body, // raw Buffer or string req.headers, // IncomingHttpHeaders process.env.WEBHOOK_SECRET!, ); console.log('Received', event.event); res.sendStatus(200); } catch (err) { // TrillboardsError { code: 'webhook_signature_invalid', statusCode: 401 } res.sendStatus(401); } }); ``` Override the replay window (default 300s): ```ts Webhooks.constructEvent(req.body, req.headers, secret, { tolerance: 600 }); ``` ## Manual verification (no SDK) ```js const crypto = require('crypto'); function verifyTrillboardsWebhook(rawBody, headers, secret, toleranceSec = 300) { const sigHeader = headers['x-trillboards-signature']; if (!sigHeader) throw new Error('missing signature header'); // Parse `t=,v0=,v1=` const parts = Object.fromEntries( sigHeader.split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }) ); const ts = Number(parts.t); if (!Number.isFinite(ts)) throw new Error('missing t='); // Replay protection const now = Math.floor(Date.now() / 1000); if (Math.abs(now - ts) > toleranceSec) throw new Error('timestamp outside tolerance'); // Prefer v1; fall back to v0 if (parts.v1) { const expected = crypto.createHmac('sha256', secret) .update(`${ts}.${rawBody}`) .digest('hex'); if (parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) { return JSON.parse(rawBody); } } if (parts.v0) { const expected = crypto.createHmac('sha256', secret) .update(rawBody) .digest('hex'); if (parts.v0.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v0), Buffer.from(expected))) { return JSON.parse(rawBody); } } throw new Error('signature mismatch'); } ``` ## Replay window Default tolerance: **300 seconds** (Stripe-aligned). Configurable per partner via `network_config.webhook_replay_tolerance_seconds` on the partner record. Outside-window deliveries are rejected with HTTP 401 + a structured error envelope (`code: webhook_signature_invalid`). ## Migration timeline | Phase | Status | Server emits | SDK reads | |---|---|---|---| | Pre-0.3.0 (broken) | done | `sha256=` + separate `X-Trillboards-Timestamp` | only `payload` — FAILS verification | | 0.3.0 dual-emit (NOW) | active | `t=,v0=,v1=` | parses compound + replay window | | Post-T+180d | future | `t=,v1=` only (drop v0=) | unchanged | The `v0=` value is emitted **only during the migration window** so partners who built their own body-only verifier (matching the original dsp-api.yaml example) pass verification for the first time. After T+180 days, once `@trillboards/api-client@0.3.0` adoption is confirmed via npm download stats, `v0=` is dropped in a separate PR. Nothing schedules that drop: the npm download share is the trigger and a human has to read it, so this paragraph is the only thing that remembers. The drop itself is one place — `services/webhooks/canonicalSigner.js`, which builds the `t=,v0=,v1=` header for every outbound surface listed below. ## Industry alignment The compound header shape is byte-for-byte compatible with Stripe's `Stripe-Signature` parser. A partner who already integrated Stripe webhooks can copy their parsing helper unchanged; only the secret and signed input change. ## Outbound surfaces using this contract - **Partner API webhooks** (`services/partner/webhookQueue.js`) — emitted on events declared in partner-api.yaml (`device.online`, `device.offline`, `impression.recorded`, `campaign.allocated`, `payout.processed`, `programmatic.*`). - **DSP API webhooks** (`services/programmatic/dspWebhookDispatcher.js`) — emitted to seats subscribed via `POST /openrtb/v2/webhooks`. Currently delivers `impression.delivered`; events declared in dsp-api.yaml. Both call `services/webhooks/canonicalSigner.js#buildSignatureHeader` so the format cannot drift between them. ## NOT covered by this document (different verifier per sender) - `routes/webhooks/github.js` — GitHub inbound, `X-Hub-Signature-256` - `middleware/vapiAuth.js` — Vapi inbound, `vapi-signature` - `services/partner/impressionSecurity.js` — Trillboards impression-pixel inbound verifier, custom `timestamp.adid.impid.did.sid.aid` format - `modules/mdm/services/alertService.js` — internal MDM dashboard, `X-MDM-Signature` - `controller/openrtb/feinWebhookController.js` — FEIN signal partners, `X-FEIN-Signature: sha256=` (body-only) These are scoped to their respective senders/consumers and are not part of the Trillboards public-partner webhook contract. ## Test plan A cross-package golden-vector test at `trillboards-api-client/tests/interop.test.ts` imports `services/webhooks/canonicalSigner.js#buildSignatureHeader` directly and asserts that: 1. The compound header parses + verifies through `Webhooks.constructEvent`. 2. Modified payloads are rejected. 3. Expired timestamps are rejected (replay protection). 4. v0-only headers verify when v1 is absent (back-compat path). 5. The header shape is byte-stable across regenerations (locked vector). If `buildSignatureHeader`'s output changes without a coordinated SDK update, this test fails loudly in CI before any partner is affected. --- # Native IMA SDK (Android) # Source: https://api.trillboards.com/docs/guides/integrations/partner-native-ima-sdk.md # Trillboards Partner Integration Guide — Native IMA SDK (Android) **Status:** Production-verified. First successful integration: Take 10 Media, April 2026. **Fill rate achieved:** 61% (Adipolo). Completion rate: 79% of fills. Effective: 48% of all ad requests. This guide documents the exact integration that works for native Android IMA SDK on Rockchip-class devices. Follow it literally — every point here was discovered the hard way through a week of debugging. --- ## 0. Integration requirements — read this before you write any code Every line below is a hard requirement, not a suggestion. Each one exists because a partner shipped without it and lost revenue or went black on-screen. If you only read one section of this guide, read this one. **R1 — The waterfall is a LOOP, not a single tag.** `/ads` returns `header_bidding_settings.vast_waterfall.sources[]` — an **array**. You must iterate **every** source, in `priority` **ascending** order, and skip any source with `enabled: false`. Reading `sources[0].vast_url` and stopping is the single most common integration bug we see. Response shape: [§4](#4-the-ad-request--get-v1partnerdevicedeviceidads). Loop contract + pseudocode: [§4.1](#41-the-waterfall-loop-contract). **R2 — Advance on EVERY failure, not just "no ad".** Move to the next source on no-fill, empty/zero-`` VAST, HTTP error, per-source timeout, IMA **load** error, *and* IMA **playback** error. Named VAST codes you must treat as "try the next source", never as "stop": **303** (no ads VAST response after wrapper), **300** (wrapper URI unparseable/timeout), **301** (wrapper timeout / unresolvable), **1009** (empty VAST response). See [§4.1](#41-the-waterfall-loop-contract). **R3 — Budget both clocks.** Each source has its own `timeout_ms` — apply it per source. The whole loop is capped by `vast_waterfall.total_timeout_ms` — when that budget is spent, stop trying sources and return to content. Never let one slow source eat the whole break. **R4 — ALWAYS handle player error. Never leave the surface black.** Register `Player.Listener.onPlayerError` (Media3) or `AdErrorEvent.AdErrorListener` + your own content-playback error path (raw IMA). On any error you must fall back to your own content or a house creative. A black screen is worse than no ad — the venue sees it, and so does the earner. The exact ExoPlayer footgun that produced a fleet of black screens is worked through in [§6.1](#61-pitfall-the-uriempty-content-resume-black-screen-media3--ima). **R5 — Fire the `` beacon.** Every VAST we serve — the programmatic wrap path *and* the first-party `trillboards_direct` rail — carries an `` element with the `[ERRORCODE]` macro. On any ad failure, substitute the numeric IMA/VAST error code for `[ERRORCODE]` and fire that URL. On the first-party rail this beacon is the **only** ad-failure signal that exists: we ship no agent to your player, so if you do not fire it we are structurally blind to your failures and cannot tell "nothing broke" from "everything broke". See [§7.1](#71-the-error-beacon--mandatory). **R6 — Report `programmatic-event`.** `ad_started` / `ad_ended` / `no_fill` / `error`, each with `request_id` + `variant_name` from the source you actually used. See [§7](#7-reporting-events-back--post-v1partnerdevicedeviceidprogrammatic-event). ### Reference implementation and self-verification - **Reference implementation:** [`trillboard-screen/public/partner-sdk/trillboards-lite.src.js`](https://github.com/trillboards/packages/blob/production/trillboard-screen/public/partner-sdk/trillboards-lite.src.js) — the Trillboards Lite SDK. Its `onAdError` handler is the canonical shape of R2 + R5: it classifies 303/1009 as no-fill, fires the `` pixel with `[ERRORCODE]` substituted, records the VAST request outcome, reports the `programmatic-event`, and **only** resolves the caller once the pending waterfall sources are exhausted. When in doubt about ordering or classification, copy what it does. - **Self-verification:** append **`?test=1`** to your `/ads` call. That forces a guaranteed-fill test source (public alias `src_test`, a direct MP4 with no wrapper chain) into that one response — no allowlist enrollment, no revenue impact, real devices polling without the flag are unaffected. If `src_test` plays end-to-end but real sources don't, your VAST handling is fine and the problem is in the partner-response path. If `src_test` *doesn't* play, the bug is in your player. --- ## 1. What this guide is for You are integrating the Trillboards `/ads` endpoint into a native Android player that uses Google IMA SDK (not WebView). Your devices are digital signage — kiosks, TVs, menu boards, ATM toppers — running a custom Android app with Media3 + ImaAdsLoader or raw IMA SDK. This path is the highest-performing integration for partner devices. It gives you server-side wrapper chain resolution, device-specific demand targeting, and full impression attribution. --- ## 2. Required endpoints (five of them) All base URLs: `https://api.trillboards.com`. Partner API key auth on registration only. | # | Method | Path | Purpose | |---|---|---|---| | 1 | POST | `/v1/partner/device/{deviceId}/heartbeat` | Every 60s. Reports device identity. | | 2 | GET | `/v1/partner/device/{deviceId}/ads` | Request waterfall of VAST sources. | | 3 | GET | `/openrtb/v1/vast-wrap/{provider}/{screenId}?url=...&req=...&var=...&pt=partner_api` | Fetch resolved VAST XML for a waterfall source. | | 4 | POST | `/v1/partner/device/{deviceId}/programmatic-event` | Report ad_started / ad_ended / no_fill / error. | | 5 | (automatic) | Tracking pixels embedded in the VAST | IMA SDK fires these when the ad plays. | --- ## 3. The heartbeat — player identity **Endpoint:** `POST /v1/partner/device/{deviceId}/heartbeat` (every 60 seconds) For a user-agent update, the supported body is `{"device":{"ua":""}}`. The empty body remains a liveness heartbeat but provides no player UA. The following example includes optional hardware facts: ```json { "device": { "make": "Rockchip", "model": "rk3568_r", "os": "Android", "osv": "11", "android_api_level": 30, "ua": "Mozilla/5.0 (Linux; Android 11; rk3568_r Build/RD2A.211001.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/105.0.5195.136 Safari/537.36" }, "application": { "agentVersion": "2.0.18" } } ``` ### Field-by-field rules **`device.make`** — The actual manufacturer reported by the device; use a known hardware value, not a guessed vendor. **`device.model`** — The actual hardware model. Keep internal serial numbers and asset IDs in their identifier fields. **`device.os`** — `Android`; report the release version separately in `device.osv`. **`device.osv`** — The Android release version, for example `"11"` or `"13"`, from [`Build.VERSION.RELEASE`](https://developer.android.com/reference/android/os/Build.VERSION#RELEASE). Send `Build.VERSION.SDK_INT` separately as the integer `device.android_api_level`, for example `30` or `33`. This matches [OpenRTB's OS-version field](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3218---object-device). **`device.ua`** — **The single most important field.** Read it directly from the device at app startup: ```kotlin val webView = WebView(context) val realUA = webView.settings.userAgentString webView.destroy() ``` Or if WebView can't be instantiated (non-certified devices may restrict it): ```kotlin val realUA = WebSettings.getDefaultUserAgent(context) ``` Send the exact UA returned by the installed WebView. Do not append serial numbers or manufacture unique strings, Build fingerprints, or `wv` tokens. Devices with the same software and hardware can legitimately share a UA. [`WebSettings.getDefaultUserAgent`](https://developer.android.com/reference/android/webkit/WebSettings#getDefaultUserAgent(android.content.Context)) returns the platform default. **`application.agentVersion`** — Your SDK/app version. Helps us correlate issues to specific rollouts. ### Hardware model and asset identifiers Keep serial numbers and asset-tracking IDs in the partner device identifier. If a device's model property has been repurposed as a serial number, use a verified hardware model or omit the optional model field. Preserve the UA returned by WebView. --- ## 4. The ad request — GET /v1/partner/device/{deviceId}/ads **Endpoint:** `GET /v1/partner/device/{deviceId}/ads` **Optional query params** (player truth — send whatever you know): - `slot_w`, `slot_h` — Actual video container dimensions in pixels - `orientation` — `portrait` or `landscape` - `muted` — `1` or `0` - `autoplay` — `1` or `0` - `ua` — The real WebView UA (same as heartbeat), URL-encoded once by your URL builder - `lang` — `en-US` etc. - `screen_w`, `screen_h` — Physical screen dimensions in pixels - `sdk_type` — `google-ima` - `sdk_version` — IMA SDK version you're linking against - Hardware and dimension fields are optional. Send actual pixel dimensions; omit unknown values. Start the UA update on a few identified devices and verify their outgoing requests before a fleet rollout. - `ipd` — Inventory Partner Domain root domain, for example `trillboards.com`. Only send this when Trillboards tells you to; it must match an `inventorypartnerdomain=` declaration in the relevant ads.txt/app-ads.txt inventory-sharing setup, and it must not include a scheme, path, query, or port. **Response shape:** ```json { "success": true, "data": { "header_bidding_settings": { "enabled": true, "vast_waterfall": { "enabled": true, "total_timeout_ms": 15000, "sources": [ { "name": "adipolo", "vast_url": "https://api.trillboards.com/openrtb/v1/vast-wrap/adipolo/{screenId}?url=...&req=req_...&var=adipolo&pt=partner_api", "timeout_ms": 10000, "priority": 1, "enabled": true, "cpm": 5.0, "degraded": false, "request_id": "req_1775716769503_eb810d042466" }, { "name": "vidverto", "vast_url": "https://api.trillboards.com/openrtb/v1/vast-wrap/vidverto/{screenId}?url=...&req=req_...&var=vidverto&pt=partner_api", "timeout_ms": 10000, "priority": 2, "enabled": true, "cpm": 0.8, "degraded": false, "request_id": "req_1775716769503_8a85f754fd8a" }, { "name": "trillboards_direct", "vast_url": "https://api.trillboards.com/openrtb/v1/vast?screenId=...&adId=...", "timeout_ms": 8000, "priority": 5, "enabled": true, "duration_seconds": 15, "request_id": "req_1775716769503_c31f0a9be7d2" } ] } }, "screen_id": "69cce...", "dooh_signals": { ... } } } ``` The `vast_url` values already point to our vast-wrap endpoint with `pt=partner_api`. **Do not rewrite them.** Use them exactly as given. Each source carries its own `request_id` and `name` — save these. You'll need them for the `programmatic-event` report later (`variant_name` on the event is the source's `name`). #### `duration_seconds` — for continuous loop playback The first-party `trillboards_direct` source carries **`duration_seconds`**: the whole-second length of the creative that source will serve. It is there so you can schedule a continuous loop **without** fetching the VAST and probing the MP4 first. - It is the **same number** as the `` in the VAST that `vast_url` returns — the two are computed by one function server-side and cannot disagree. Use whichever is convenient. - It is **scheduling metadata, not a playback control.** IMA takes its real timing from the media itself, so keep honouring the actual quartile and `complete` events rather than assuming the ad ended after `duration_seconds` elapsed. If the two ever disagree on your device, the *events* are right and that is a bug worth telling us about. - Programmatic sources do **not** carry it — a DSP's creative length is unknown until its own VAST is fetched. Read it only off `trillboards_direct`, and treat it as absent everywhere else. - A creative longer than your ad slot will still be cut off by the next `/ads` poll. We warn the advertiser at upload time when their creative exceeds the slot, so this should be rare — but do not assume `duration_seconds` fits your slot without checking. Programmatic `/ads` responses are intentionally not HTTP-cacheable. Treat each call as a fresh ad opportunity because the VAST URLs include per-request IDs, correlators, and yield-routing metadata. Static direct-ad responses may still use short private ETag caching. ### 4.1 The waterfall loop contract **`sources` is an ordered array and you must walk all of it.** This is not optional and it is not an optimization — it is how a Trillboards ad request is defined. We return the array already in the order we want it tried, and `priority` ascends with array position. Sort by `priority` ascending anyway: it costs you three lines and it makes your player correct if the transport, a cache, or a JSON library ever reorders the array. **Treat `priority` as an opaque ascending sort key — not an index.** Do not assume it starts at 1, and do not assume the values are contiguous: - **`priority: 0` is real and valid.** A source we want to win outright is stamped `priority: 0` — that includes the `?test=1` source, an injected first-party bid, and an auction winner. A player that treats `0` as "unset" and skips it, or that starts its loop at 1, will drop exactly the source we most wanted served. - **Gaps are normal.** Some paths renumber the surviving sources `1..N`; others leave the original catalog values, so `11` and `13` with nothing in between is a perfectly ordinary response. Only the relative order carries meaning. `sortBy(priority)` handles all of this; arithmetic on the values does not. ``` sources ← response.data.header_bidding_settings.vast_waterfall.sources sources ← sources.filter(s -> s.enabled != false).sortBy(s -> s.priority) // ascending deadline ← now() + vast_waterfall.total_timeout_ms for (source in sources): if now() >= deadline: break // R3 — whole-loop budget spent vast ← GET source.vast_url with timeout = source.timeout_ms if (transport failed) or (timed out) or (vast is empty) or (vast has zero ): lastTried ← source; continue // R2 — advance outcome ← handToIma(vast) if outcome is AD_LOAD_ERROR or AD_PLAY_ERROR: fire beacon with [ERRORCODE] // R5 — always lastTried ← source; continue // R2 — advance report ad_started / ad_ended for this source // §7 return PLAYED // stop at the FIRST source that plays // every source exhausted, or budget spent report no_fill for lastTried // §7 resumeOwnContent() // R4 — never leave the surface black ``` **Rules the pseudocode encodes:** - **Iterate all sources by `priority` ascending.** Stop only on the first source that *actually plays*. - **Skip `enabled: false`.** A source can be present-but-disabled in the array; treat it as if it weren't there. - **Advance on every failure class**, not just "no ad": no-fill, empty VAST, zero-`` VAST, HTTP/transport error, per-source timeout, IMA **load** error, and IMA **playback** error. A creative that loads and then fails mid-roll still means "this source did not deliver" — go to the next one. - **VAST error codes that mean "advance", never "stop":** | Code | Meaning | What you do | |---|---|---| | **303** | No ads VAST response after one or more wrappers | Next source | | **300** | Wrapper URI unparseable, unsupported, or wouldn't load | Next source | | **301** | Timeout / unresolvable URI in the wrapper chain | Next source | | **1009** | Empty VAST response | Next source | - **Two clocks.** `source.timeout_ms` bounds one source's fetch+load. `vast_waterfall.total_timeout_ms` bounds the whole loop. Honour both; when the total budget is gone, stop and return to content. - **`degraded: true`** means we've measured a high recent error rate for that source. We already push degraded sources to the tail of the array for you — you don't need to act on the flag, and you must **not** skip a source just because it's degraded. - **Report against the source you actually used.** `request_id` and `variant_name` come from *that* source's entry, not from `sources[0]`. The `` beacon (R5) fires per failed source, always. The `programmatic-event` minimum is unchanged — the source that played, plus one `no_fill` for the last source tried if none did ([§7](#7-reporting-events-back--post-v1partnerdevicedeviceidprogrammatic-event)) — but a per-source `error` report is accepted and makes your waterfall legible to us end to end. > **This is the exact bug that cost a partner a full production forensics session.** Their player read `sources[0].vast_url` only. Indexes 0–3 were programmatic DSPs that returned no-fill for their geo; the `trillboards_direct` house creative sat at index 4. Their player got VAST **303** on source 0 and stopped — one index before the ad it was looking for. Every request looked like "Trillboards has no demand". The demand was there the whole time, one loop iteration away. --- ## 5. Fetching VAST — the critical part For **each** source in the waterfall — all of them, in `priority` ascending order, per the loop contract in [§4.1](#41-the-waterfall-loop-contract): ``` GET {source.vast_url} # with a per-request timeout of source.timeout_ms ``` **Response:** Full VAST XML (Content-Type: application/xml), 15-25 KB, containing wrapper chains resolved server-side. Our server: 1. Fetches the upstream partner VAST (Adipolo, VidVerto, etc.) using your device's IP via `X-Forwarded-For` for geo-targeting 2. Follows `` wrapper chains up to 5 hops to resolve to inline creative 3. Injects Trillboards tracking pixels at the wrapper/inline level 4. Returns the final VAST XML ready for IMA SDK to play **Do NOT:** - Parse the XML and extract only `` - Strip `` elements - Check for VPAID and skip (let IMA SDK handle it) - Modify the XML in any way before passing to IMA **Why:** Our tracking pixels (`` and ``) are embedded at the wrapper level. If you strip them, IMA SDK never fires them, and we have no record of the impression → no revenue attribution → no earnings. --- ## 5b. If you're NOT using Google IMA SDK (raw VAST player) IMA SDK automatically fires every embedded `` and `` URL as the ad plays. Custom VAST players do **not**. If you are rolling your own — raw HTML5 `