DSP API
Programmatic buying for DOOH inventory. Discover screens, upload creatives, and serve ads — all via REST. Real-time audience signals no other SSP can match.
Auth Flow (5 calls, zero to serving ads)
From an empty terminal to a creative playing on a Trillboards screen in five steps.
Step 1 — Register your DSP seat
/onboard is the only unauthenticated endpoint. It returns your API key once — store it immediately.
curl -X POST https://api.trillboards.com/openrtb/v2/onboard \
-H "Content-Type: application/json" \
-d '{
"company_name": "Acme Advertising",
"seat_id": "acme_ads",
"contact_email": "buyer@acme-ads.com"
}'
# Response 201:
# {
# "success": true,
# "data": {
# "seat_id": "acme_ads",
# "api_key": { "key": "tb_dsp_a1b2c3..." } <-- store this
# }
# }Step 2 — Browse inventory
Authenticate with the key (either header form works). The response includes FEIN audience signals per screen under ext.trb.fein.
curl "https://api.trillboards.com/openrtb/v2/adslots?country=US&venue_type=retail" \
-H "x-api-key: tb_dsp_YOUR_KEY"
# or:
# -H "Authorization: Bearer tb_dsp_YOUR_KEY"Step 3 — Upload a creative
Two modes: by URL (we fetch and moderate) or by presigned upload (you confirm after upload). Either way, Gemini moderation takes ~30 seconds.
curl -X POST https://api.trillboards.com/openrtb/v2/creatives \
-H "x-api-key: tb_dsp_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer Sale 15s",
"content_type": "video/mp4",
"creative_url": "https://cdn.example.com/ads/summer-sale.mp4"
}'
# Poll for moderation status:
# GET /openrtb/v2/creatives/:id -> status: pending -> approvedStep 4 — Create a campaign
curl -X POST https://api.trillboards.com/openrtb/v2/campaigns \
-H "x-api-key: tb_dsp_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"creative_id": "CREATIVE_ID",
"name": "Summer Retail Push",
"targeting": { "country": "US", "venue_type": "retail" }
}'Step 5 — Assign to screens
Screens pick up new placements within 30-60 seconds (heartbeat cadence).
curl -X POST https://api.trillboards.com/openrtb/v2/campaigns/assign \
-H "x-api-key: tb_dsp_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"creative_id": "CREATIVE_ID",
"screen_ids": ["SCREEN_1", "SCREEN_2", "SCREEN_3"],
"start_date": "2026-05-15",
"end_date": "2026-06-15"
}'Why Trillboards
Real-time audience signals
FEIN edge AI provides live face count, dominant emotion, attention, and (where speech analysis is enabled) purchase-intent signals on every screen — at sub-10-second freshness from the on-device pipeline.
Cryptographic proof-of-play
Every impression is Ed25519-signed and third-party verifiable. No trust required.
AI-moderated creatives
Gemini AI automatically classifies content (IAB categories, age rating, brand safety) in ~30 seconds.
Full reporting pipeline
Per-screen breakdown, time-series trends, fill rate, and CPM analytics via API.
Authentication
All endpoints except /onboard and /openapi.json require an API key. Include it as a header:
# Option 1: x-api-key header
curl -H "x-api-key: tb_dsp_YOUR_KEY" ...
# Option 2: Bearer token
curl -H "Authorization: Bearer tb_dsp_YOUR_KEY" ...Each API key is issued with a set of scopes(e.g. bid:write, inventory:read, campaigns:write). Routes that require a scope you do not hold return 403 missing_scope with required_scope and granted_scopes in the response — see the endpoint table below for the scope each route requires.
New DSPs start in sandbox mode (100 req/min). Sandbox bids are validated but don't enter the live auction. Sandbox keys pass all scope checks (you can integrate against the full surface immediately). Contact support@trillboards.com to upgrade to production (1,000 req/min).
API Endpoints
Full enumeration of /openrtb/v2/*. The Scope column names the scope requireScope(...) enforces per route.
Onboarding
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /openrtb/v2/onboard | — | Self-register a DSP seat. Returns the API key (shown once). |
Real-time bidding
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /openrtb/v2/bid | bid:write | Submit OpenRTB 2.6 bid response (cached for waterfall injection). |
| GET | /openrtb/v2/stats | stats:read | 24h performance metrics: bids, wins, CPM, fill, latency. |
Inventory discovery
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /openrtb/v2/adslots | inventory:read | Browse screens with live FEIN signals (face count, attention, emotion, VAS). |
| POST | /openrtb/v2/forecast | inventory:read | Estimate delivery for a budget + targeting tuple. |
| POST | /openrtb/v2/discover | inventory:read | Semantic inventory search ("busy coffee shops in NYC"). |
| GET | /openrtb/v2/screens/:id | inventory:read | Full screen profile (venue, hours, audience composition, recent CPM). |
| POST | /openrtb/v2/screens/availability | inventory:read | Bulk capacity check for a screen set. |
| POST | /openrtb/v2/screens/audience | inventory:read | Bulk audience profiles for a screen set. |
Creatives (AI moderation pipeline)
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /openrtb/v2/creatives | campaigns:write | Upload creative by URL or get a presigned S3 URL. Enqueues Gemini moderation. |
| GET | /openrtb/v2/creatives | campaigns:write | List your creatives with moderation status. |
| GET | /openrtb/v2/creatives/:id | campaigns:write | Creative detail: IAB classification, content flags, age rating, brand recognition. |
| POST | /openrtb/v2/creatives/:id/confirm | campaigns:write | Confirm presigned upload completed. Triggers AI moderation. |
Campaigns + reservations
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /openrtb/v2/campaigns | campaigns:write | Create a campaign (approved creative + targeting). |
| POST | /openrtb/v2/campaigns/assign | campaigns:write | Place creative on specific screens (30-60s delivery). |
| POST | /openrtb/v2/reservations | campaigns:write | Hold inventory capacity (15-min TTL). |
| GET | /openrtb/v2/reservations | campaigns:write | List your reservations. |
| GET | /openrtb/v2/reservations/:id | campaigns:write | Reservation status + screens held. |
| POST | /openrtb/v2/reservations/:id/confirm | campaigns:write | Convert reservation to a permanent placement. |
| DELETE | /openrtb/v2/reservations/:id | campaigns:write | Release a reservation hold. |
Deals (PMP / preferred / guaranteed)
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /openrtb/v2/deals | deals:read | List deals offered to your seat. |
| GET | /openrtb/v2/deals/:dealId | deals:read | Deal details — floor, caps, dates. |
| POST | /openrtb/v2/deals/propose | campaigns:write | Propose a PMP deal (admin approval required). |
Market insights
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /openrtb/v2/market/cpm-benchmarks | stats:read | Percentile CPM benchmarks by venue type / geography. |
| GET | /openrtb/v2/market/demand-analysis | stats:read | Demand-side insights — competitive pressure, win-rate context. |
Proof-of-play + reporting
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /openrtb/v2/proof-of-play | proof:read | Ed25519-signed proof-of-play records for your campaigns. |
| GET | /openrtb/v2/analytics/funnel | reports:read | VAST event funnel (request → fill → start → complete). |
| GET | /openrtb/v2/reports/summary | reports:read | Aggregated impressions, CPM, fill rate. |
| GET | /openrtb/v2/reports/screens | reports:read | Per-screen performance breakdown. |
| GET | /openrtb/v2/reports/timeseries | reports:read | Hourly or daily impression + CPM trends. |
Webhooks
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /openrtb/v2/webhooks | campaigns:write | Register a webhook URL. |
| GET | /openrtb/v2/webhooks | campaigns:write | List your registered webhooks. |
| GET | /openrtb/v2/webhooks/deliveries | campaigns:write | Delivery log (last 7 days) for debugging. |
| DELETE | /openrtb/v2/webhooks/:id | campaigns:write | Remove a webhook subscription. |
Discovery
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /openrtb/v2/openapi.json | — | Machine-readable OpenAPI 3.0 spec (cached 5 min). Always discoverable, no auth required. |
AMP / Apparatix-style inventory ingestion
Some buyers (Apparatix-style product catalogs, AMP partners, agency tooling) ingest DOOH inventory once an hour by cron-polling /adslots and upserting screens into their own product table. The mapping below shows how the Trillboards response maps to that schema.
Field mapping
| Trillboards field | Your product field | Notes |
|---|---|---|
| screen_id | external_id | Stable identifier; safe upsert key. |
| name | product_name | Human-readable screen name. |
| address | location | Postal address. |
| venue_type | product_type | retail / restaurant / gym / transit / office / etc. |
| slot_duration_seconds | ad_duration | Default 15s; varies per screen. |
| floor_price_cpm | rate_card | USD floor CPM for this screen. |
| vendor (constant) | vendor | Hard-coded to "Trillboards". |
Hourly cron — bash
# Every hour at minute 5
5 * * * * curl -s -H "x-api-key: $TRILLBOARDS_API_KEY" \
"https://api.trillboards.com/openrtb/v2/adslots" \
> /var/lib/your-product/trillboards-adslots.jsonNode.js — hourly ingestion job
// Pulls /adslots and upserts into a Mongo `products` collection.
// Drop into a cron job, or schedule via your favorite job runner.
const fetch = require('node-fetch');
const { MongoClient } = require('mongodb');
const API_KEY = process.env.TRILLBOARDS_API_KEY;
const MONGO_URL = process.env.MONGO_URL;
async function ingest() {
const res = await fetch(
'https://api.trillboards.com/openrtb/v2/adslots',
{ headers: { 'x-api-key': API_KEY } }
);
if (!res.ok) throw new Error(`adslots ${res.status}`);
const body = await res.json();
const screens = body?.data?.screens ?? [];
const client = await MongoClient.connect(MONGO_URL);
const products = client.db().collection('products');
for (const s of screens) {
await products.updateOne(
{ external_id: s.screen_id },
{
$set: {
external_id: s.screen_id,
product_name: s.name,
location: s.address,
product_type: s.venue_type,
ad_duration: s.slot_duration_seconds ?? 15,
rate_card: s.floor_price_cpm,
vendor: 'Trillboards',
updated_at: new Date(),
},
},
{ upsert: true }
);
}
await client.close();
console.log(`upserted ${screens.length} screens`);
}
ingest().catch((e) => { console.error(e); process.exit(1); });Python — hourly ingestion job
# Drop into your scheduler (Airflow, cron, Prefect, etc.).
import os
import requests
from pymongo import MongoClient, UpdateOne
from datetime import datetime, timezone
API_KEY = os.environ['TRILLBOARDS_API_KEY']
MONGO_URL = os.environ['MONGO_URL']
def ingest():
resp = requests.get(
'https://api.trillboards.com/openrtb/v2/adslots',
headers={'x-api-key': API_KEY},
timeout=30,
)
resp.raise_for_status()
screens = resp.json().get('data', {}).get('screens', [])
client = MongoClient(MONGO_URL)
products = client.get_default_database()['products']
ops = []
for s in screens:
ops.append(UpdateOne(
{'external_id': s['screen_id']},
{'$set': {
'external_id': s['screen_id'],
'product_name': s['name'],
'location': s.get('address'),
'product_type': s.get('venue_type'),
'ad_duration': s.get('slot_duration_seconds', 15),
'rate_card': s.get('floor_price_cpm'),
'vendor': 'Trillboards',
'updated_at': datetime.now(timezone.utc),
}},
upsert=True,
))
if ops:
products.bulk_write(ops, ordered=False)
client.close()
print(f'upserted {len(ops)} screens')
if __name__ == '__main__':
ingest()Delta-sync via ?since=<ISO8601> / ?cursor=<token> is rolling out — when available, your job can request only screens updated since the last sync timestamp. Until then, full re-fetch each tick is the supported pattern (the response is gzipped and caches well for hourly polls).
Error Catalog
Every 4xx and 5xx response across /openrtb/v2/*uses one envelope. The error_code field is the machine-parseable contract; error is a human-readable string; request_id correlates to our logs (include it in support emails).
{
"success": false,
"error": "<human-readable string>",
"error_code": "<snake_case_code>",
"request_id": "req_<uuid>",
// ...optional extras: param, retry_after_seconds,
// required_scope, granted_scopes, nbr (bid only)
}Retry guidance: 4xx are client errors — fix the payload, do not retry the same call. 429 is rate-limit — sleep for retry_after_seconds (or honor the Retry-After header) and retry. 5xx is server-side — exponential backoff (1s, 2s, 4s, 8s, max 60s) up to 5 attempts, then surface the request_id in a support ticket.
Authentication / authorization
| error_code | HTTP | Retry | Meaning |
|---|---|---|---|
| authentication_required | 401 | never | API key missing or DSP context not populated. |
| not_authorized | 403 | never | API key resolved but does not own the requested resource. |
| missing_scope | 403 | never | Key lacks the scope required by this route. extras.required_scope names the missing scope; extras.granted_scopes lists what the key has. |
Validation (fix the payload, do not retry)
| error_code | HTTP | Retry | Meaning |
|---|---|---|---|
| missing_required_field | 400 | never | extras.param names the missing field. |
| missing_name | 400 | never | Creative / campaign upload missing the name field. |
| missing_content_type | 400 | never | Creative upload missing content_type. |
| missing_creative_id | 400 | never | Campaign / assignment missing creative_id. |
| missing_floor_cpm | 400 | never | Deal proposal missing floor_cpm. |
| missing_screen_ids | 400 | never | Assignment missing the screen_ids[] array. |
| missing_asset_url | 400 | never | Creative upload missing asset_url or creative_url. |
| missing_deal_id | 400 | never | Deal lookup missing deal_id. |
| invalid_email | 400 | never | contact_email failed format check on onboarding. |
| invalid_seat_id | 400 | never | seat_id must match ^[a-z0-9_]+$ and be 3-40 chars. |
| invalid_screen | 400 | never | screen_id does not resolve to a known Trillboards screen. |
| invalid_budget | 400 | never | Campaign budget out of allowed range or wrong type. |
| invalid_date | 400 | never | Date param is not ISO-8601 or violates start < end. |
| invalid_webhook_url | 400 | never | Webhook endpoint must be https:// and not localhost / private RFC1918. |
| invalid_verification_tier | 400 | never | Requested verification tier not enabled for this seat. |
| invalid_bid_response | 400 | never | Bid payload failed OpenRTB 2.6 schema validation. extras.nbr carries the no-bid reason code. |
| invalid_events | 400 | never | Webhook subscribe listed events outside the allowlist. |
Resource state
| error_code | HTTP | Retry | Meaning |
|---|---|---|---|
| seat_id_already_registered | 409 | never | Onboarding with a seat_id that already exists. Reuse the existing key (or pick a different seat_id). |
| already_exists | 409 | never | Generic conflict on a unique-key insert. |
| deal_already_exists | 409 | never | POST /deals/propose against an existing deal_id. |
| not_found | 404 | never | Resource id not resolvable to anything the seat owns. |
| creative_not_found | 404 | never | creative_id does not exist or is not owned by this seat. |
| screen_not_found | 404 | never | screen_id does not exist. |
| reservation_not_found | 404 | never | reservation_id does not exist or is not owned by this seat. |
| deal_not_found | 404 | never | deal_id does not exist. |
| webhook_not_found | 404 | never | webhook_id does not exist or is not owned by this seat. |
| expired | 410 | never | Reservation has passed its 15-min TTL. Create a new one. |
Throttling
| error_code | HTTP | Retry | Meaning |
|---|---|---|---|
| rate_limited | 429 | after retry_after_seconds | Rate limit exceeded. extras.retry_after_seconds carries the cooldown; the Retry-After HTTP header is also set. |
Server
| error_code | HTTP | Retry | Meaning |
|---|---|---|---|
| api_key_provisioning_failed | 500 | exponential backoff | Onboard succeeded at the DB layer but the key minting step failed. Re-call onboard. |
| internal_error | 500 | exponential backoff | Caught uncaught exception. Retry once; if persistent, email developers@trillboards.com with the request_id. |
| unspecified_error | 500 | exponential backoff | Reserved fallback. Treat as internal_error. |
FEIN Audience Signals
Trillboards screens run edge AI (FEIN) that provides real-time audience data no other SSP can match. These signals are included in the /adslots response under ext.trb.fein.
| Signal | Type | Freshness |
|---|---|---|
| live_face_count | integer | 10s (edge) |
| attention_score | 0-1 | 10s (edge) |
| dominant_emotion | string | 10s (edge) |
| vas_7d | number | Hourly |
| crowd_density | integer | 10s (edge) |
| purchase_intent | string | Per-utterance (speech) |
| income_level | enum | Per ad opportunity (cloud) |
| ad_receptivity | 0-1 | Per ad opportunity (cloud) |
Rate Limits
Per-seat in production; per-IP for unauthenticated /onboard. 429 responses carry retry_after_seconds and a Retry-After header.
| Group | Routes | Sandbox | Production |
|---|---|---|---|
| Bidding | /bid | 100/min | 1,000/min |
| Inventory | /adslots, /screens/*, /discover, /forecast, /proof-of-play | 100/min | 200/min |
| Campaigns | /creatives, /campaigns, /campaigns/assign, /reservations, /webhooks | 100/min | 100/min |
| Onboarding | /onboard (per IP) | 5/hr | 5/hr |
Rate limit headers are included in every response: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset.
Need help integrating? Contact developers@trillboards.com