Back to Developers

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.

Machine-readable specAI agents and automated integrations should consume the canonical OpenAPI 3.0 JSON at https://api.trillboards.com/openrtb/v2/openapi.json. A discovery descriptor is also served at /.well-known/dsp-api-discovery.json. LLM agents can fetch https://api.trillboards.com/llms.txt for a flat plain-text summary.

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.

1. POST /onboard
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.

2. GET /adslots
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.

3. POST /creatives
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 -> approved

Step 4 — Create a campaign

4. POST /campaigns
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).

5. POST /campaigns/assign
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:

Authentication
# 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

MethodEndpointScopeDescription
POST/openrtb/v2/onboardSelf-register a DSP seat. Returns the API key (shown once).

Real-time bidding

MethodEndpointScopeDescription
POST/openrtb/v2/bidbid:writeSubmit OpenRTB 2.6 bid response (cached for waterfall injection).
GET/openrtb/v2/statsstats:read24h performance metrics: bids, wins, CPM, fill, latency.

Inventory discovery

MethodEndpointScopeDescription
GET/openrtb/v2/adslotsinventory:readBrowse screens with live FEIN signals (face count, attention, emotion, VAS).
POST/openrtb/v2/forecastinventory:readEstimate delivery for a budget + targeting tuple.
POST/openrtb/v2/discoverinventory:readSemantic inventory search ("busy coffee shops in NYC").
GET/openrtb/v2/screens/:idinventory:readFull screen profile (venue, hours, audience composition, recent CPM).
POST/openrtb/v2/screens/availabilityinventory:readBulk capacity check for a screen set.
POST/openrtb/v2/screens/audienceinventory:readBulk audience profiles for a screen set.

Creatives (AI moderation pipeline)

MethodEndpointScopeDescription
POST/openrtb/v2/creativescampaigns:writeUpload creative by URL or get a presigned S3 URL. Enqueues Gemini moderation.
GET/openrtb/v2/creativescampaigns:writeList your creatives with moderation status.
GET/openrtb/v2/creatives/:idcampaigns:writeCreative detail: IAB classification, content flags, age rating, brand recognition.
POST/openrtb/v2/creatives/:id/confirmcampaigns:writeConfirm presigned upload completed. Triggers AI moderation.

Campaigns + reservations

MethodEndpointScopeDescription
POST/openrtb/v2/campaignscampaigns:writeCreate a campaign (approved creative + targeting).
POST/openrtb/v2/campaigns/assigncampaigns:writePlace creative on specific screens (30-60s delivery).
POST/openrtb/v2/reservationscampaigns:writeHold inventory capacity (15-min TTL).
GET/openrtb/v2/reservationscampaigns:writeList your reservations.
GET/openrtb/v2/reservations/:idcampaigns:writeReservation status + screens held.
POST/openrtb/v2/reservations/:id/confirmcampaigns:writeConvert reservation to a permanent placement.
DELETE/openrtb/v2/reservations/:idcampaigns:writeRelease a reservation hold.

Deals (PMP / preferred / guaranteed)

MethodEndpointScopeDescription
GET/openrtb/v2/dealsdeals:readList deals offered to your seat.
GET/openrtb/v2/deals/:dealIddeals:readDeal details — floor, caps, dates.
POST/openrtb/v2/deals/proposecampaigns:writePropose a PMP deal (admin approval required).

Market insights

MethodEndpointScopeDescription
GET/openrtb/v2/market/cpm-benchmarksstats:readPercentile CPM benchmarks by venue type / geography.
GET/openrtb/v2/market/demand-analysisstats:readDemand-side insights — competitive pressure, win-rate context.

Proof-of-play + reporting

MethodEndpointScopeDescription
GET/openrtb/v2/proof-of-playproof:readEd25519-signed proof-of-play records for your campaigns.
GET/openrtb/v2/analytics/funnelreports:readVAST event funnel (request → fill → start → complete).
GET/openrtb/v2/reports/summaryreports:readAggregated impressions, CPM, fill rate.
GET/openrtb/v2/reports/screensreports:readPer-screen performance breakdown.
GET/openrtb/v2/reports/timeseriesreports:readHourly or daily impression + CPM trends.

Webhooks

MethodEndpointScopeDescription
POST/openrtb/v2/webhookscampaigns:writeRegister a webhook URL.
GET/openrtb/v2/webhookscampaigns:writeList your registered webhooks.
GET/openrtb/v2/webhooks/deliveriescampaigns:writeDelivery log (last 7 days) for debugging.
DELETE/openrtb/v2/webhooks/:idcampaigns:writeRemove a webhook subscription.

Discovery

MethodEndpointScopeDescription
GET/openrtb/v2/openapi.jsonMachine-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 fieldYour product fieldNotes
screen_idexternal_idStable identifier; safe upsert key.
nameproduct_nameHuman-readable screen name.
addresslocationPostal address.
venue_typeproduct_typeretail / restaurant / gym / transit / office / etc.
slot_duration_secondsad_durationDefault 15s; varies per screen.
floor_price_cpmrate_cardUSD floor CPM for this screen.
vendor (constant)vendorHard-coded to "Trillboards".

Hourly cron — bash

cron entry
# 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.json

Node.js — hourly ingestion job

ingest-trillboards.js
// 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

ingest_trillboards.py
# 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).

Unified error envelope
{
  "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_codeHTTPRetryMeaning
authentication_required401neverAPI key missing or DSP context not populated.
not_authorized403neverAPI key resolved but does not own the requested resource.
missing_scope403neverKey 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_codeHTTPRetryMeaning
missing_required_field400neverextras.param names the missing field.
missing_name400neverCreative / campaign upload missing the name field.
missing_content_type400neverCreative upload missing content_type.
missing_creative_id400neverCampaign / assignment missing creative_id.
missing_floor_cpm400neverDeal proposal missing floor_cpm.
missing_screen_ids400neverAssignment missing the screen_ids[] array.
missing_asset_url400neverCreative upload missing asset_url or creative_url.
missing_deal_id400neverDeal lookup missing deal_id.
invalid_email400nevercontact_email failed format check on onboarding.
invalid_seat_id400neverseat_id must match ^[a-z0-9_]+$ and be 3-40 chars.
invalid_screen400neverscreen_id does not resolve to a known Trillboards screen.
invalid_budget400neverCampaign budget out of allowed range or wrong type.
invalid_date400neverDate param is not ISO-8601 or violates start < end.
invalid_webhook_url400neverWebhook endpoint must be https:// and not localhost / private RFC1918.
invalid_verification_tier400neverRequested verification tier not enabled for this seat.
invalid_bid_response400neverBid payload failed OpenRTB 2.6 schema validation. extras.nbr carries the no-bid reason code.
invalid_events400neverWebhook subscribe listed events outside the allowlist.

Resource state

error_codeHTTPRetryMeaning
seat_id_already_registered409neverOnboarding with a seat_id that already exists. Reuse the existing key (or pick a different seat_id).
already_exists409neverGeneric conflict on a unique-key insert.
deal_already_exists409neverPOST /deals/propose against an existing deal_id.
not_found404neverResource id not resolvable to anything the seat owns.
creative_not_found404nevercreative_id does not exist or is not owned by this seat.
screen_not_found404neverscreen_id does not exist.
reservation_not_found404neverreservation_id does not exist or is not owned by this seat.
deal_not_found404neverdeal_id does not exist.
webhook_not_found404neverwebhook_id does not exist or is not owned by this seat.
expired410neverReservation has passed its 15-min TTL. Create a new one.

Throttling

error_codeHTTPRetryMeaning
rate_limited429after retry_after_secondsRate limit exceeded. extras.retry_after_seconds carries the cooldown; the Retry-After HTTP header is also set.

Server

error_codeHTTPRetryMeaning
api_key_provisioning_failed500exponential backoffOnboard succeeded at the DB layer but the key minting step failed. Re-call onboard.
internal_error500exponential backoffCaught uncaught exception. Retry once; if persistent, email developers@trillboards.com with the request_id.
unspecified_error500exponential backoffReserved 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.

SignalTypeFreshness
live_face_countinteger10s (edge)
attention_score0-110s (edge)
dominant_emotionstring10s (edge)
vas_7dnumberHourly
crowd_densityinteger10s (edge)
purchase_intentstringPer-utterance (speech)
income_levelenumPer ad opportunity (cloud)
ad_receptivity0-1Per 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.

GroupRoutesSandboxProduction
Bidding/bid100/min1,000/min
Inventory/adslots, /screens/*, /discover, /forecast, /proof-of-play100/min200/min
Campaigns/creatives, /campaigns, /campaigns/assign, /reservations, /webhooks100/min100/min
Onboarding/onboard (per IP)5/hr5/hr

Rate limit headers are included in every response: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset.

Need help integrating? Contact developers@trillboards.com