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. Loop contract + pseudocode: §4.1.

R2 — Advance on EVERY failure, not just "no ad". Move to the next source on no-fill, empty/zero-<Ad> 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.

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.

R5 — Fire the <Error> beacon. Every VAST we serve — the programmatic wrap path and the first-party trillboards_direct rail — carries an <Error> 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.

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.

Reference implementation and self-verification


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.

#MethodPathPurpose
1POST/v1/partner/device/{deviceId}/heartbeatEvery 60s. Reports device identity.
2GET/v1/partner/device/{deviceId}/adsRequest waterfall of VAST sources.
3GET/openrtb/v1/vast-wrap/{provider}/{screenId}?url=...&req=...&var=...&pt=partner_apiFetch resolved VAST XML for a waterfall source.
4POST/v1/partner/device/{deviceId}/programmatic-eventReport ad_started / ad_ended / no_fill / error.
5(automatic)Tracking pixels embedded in the VASTIMA 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":"<actual WebView user agent>"}}. The empty body remains a liveness heartbeat but provides no player UA. The following example includes optional hardware facts:


{
  "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. 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.

device.ua — The single most important field. Read it directly from the device at app startup:


val webView = WebView(context)
val realUA = webView.settings.userAgentString
webView.destroy()

Or if WebView can't be instantiated (non-certified devices may restrict it):


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) 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):

Response shape:


{
  "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.

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:

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 <Ad>):
        lastTried ← source; continue                   // R2 — advance
    outcome ← handToIma(vast)
    if outcome is AD_LOAD_ERROR or AD_PLAY_ERROR:
        fire <Error> 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:

CodeMeaningWhat you do
303No ads VAST response after one or more wrappersNext source
300Wrapper URI unparseable, unsupported, or wouldn't loadNext source
301Timeout / unresolvable URI in the wrapper chainNext source
1009Empty VAST responseNext source

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:


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 <VASTAdTagURI> 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:

Why: Our tracking pixels (<Impression> and <TrackingEvents>) 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 <Impression> and <TrackingEvents event="..."> URL as the ad plays. Custom VAST players do not. If you are rolling your own — raw HTML5 <video> wrappers, svp, video.js without the VAST plugin, Shaka without the ads plugin, or any other non-IMA stack — you are responsible for firing those pixels yourself.

If you skip this, our server has no record of the impression. Zero revenue attribution. Zero earnings.

Extract tracking URLs directly from the VAST XML with browser-native DOMParser — no dependencies:


function parseVastTrackingUrls(vastXml) {
  const doc = new DOMParser().parseFromString(vastXml, 'application/xml');
  const getUrls = (selector) => Array.from(doc.querySelectorAll(selector))
    .map((n) => (n.textContent || '').trim())
    .filter(Boolean);
  return {
    impression:    getUrls('Impression'),
    error:         getUrls('Error'),   // fire on failure with [ERRORCODE] substituted — see §7.1
    start:         getUrls('Tracking[event="start"]'),
    firstQuartile: getUrls('Tracking[event="firstQuartile"]'),
    midpoint:      getUrls('Tracking[event="midpoint"]'),
    thirdQuartile: getUrls('Tracking[event="thirdQuartile"]'),
    complete:      getUrls('Tracking[event="complete"]'),
  };
}

function firePixels(urls) {
  urls.forEach((u) => { const img = new Image(1, 1); img.src = u; });
}

Fire order:

  1. Impression URLs on the first video frame render.
  2. start URLs immediately after the impression fire.
  3. Quartile URLs (firstQuartile, midpoint, thirdQuartile, complete) from the corresponding player lifecycle callbacks (timeupdate / ended in HTML5, equivalent events in other stacks).
  4. error URLs on any load or playback failure, with [ERRORCODE] replaced by the numeric VAST/player error code — see §7.1.

Rules:


6. Handing VAST XML to IMA SDK

Raw IMA SDK (google-ima3)


AdsRequest request = sdkFactory.createAdsRequest();
request.setAdsResponse(vastXmlString);  // the full XML, untouched
request.setContentProgressProvider(contentProgressProvider);
adsLoader.requestAds(request);

Media3 doesn't expose setAdsResponse directly — it uses a data: URI scheme that internally converts to setAdsResponse:


// 1. Get resolved VAST XML from vast-wrap
val vastXml = fetchVastWrap(source.vastUrl)

// 2. Build data: URI — Media3 internally calls setAdsResponse() for data: scheme
val adsUri = Util.getDataUriForString("text/xml", vastXml)

// 3. Pass to Media3 AdsConfiguration
val mediaItem = MediaItem.Builder()
    .setUri(contentUri)
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri)
            .setAdsId(source.requestId)  // use source.request_id for tracking
            .build()
    )
    .build()

player.setMediaItem(mediaItem)
player.prepare()

Critical: do NOT set MIME type filter


// WRONG — this blocks VPAID and non-mp4 creatives from VidVerto, AdX, etc.
ImaAdsLoader.Builder(context)
    .setAdMediaMimeTypes(listOf("video/mp4"))  // ← REMOVE THIS
    .build()

// RIGHT — let IMA SDK negotiate supported types per creative
ImaAdsLoader.Builder(context)
    .build()

The mp4 MIME filter was the single biggest completion-rate killer in Take 10's integration. Leaving it set cost 100% of VidVerto fills and some percentage of Adipolo fills. Remove it unconditionally.

6.1 Pitfall: the Uri.EMPTY content-resume black screen (Media3 + IMA)

This one is not partner-specific — it is a general IMA + ExoPlayer footgun, and it produced a fleet of black screens that took a full production forensics session to diagnose. Read it even if your integration "already works", because it only bites after an ad plays successfully.

The setup. Digital signage often has no "content" — the screen shows your own app UI or playlist, and you want the player to do nothing but play ads. So the natural thing to write is:


// WRONG — this is a black screen waiting to happen
val mediaItem = MediaItem.Builder()
    .setUri(Uri.EMPTY)                                // or "" , or omitted entirely
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri).build()
    )
    .build()

What actually happens. The ad plays fine. Then IMA fires CONTENT_RESUME_REQUESTED at ad end — it always does, because IMA's model is ad interrupts content, ad finishes, content resumes. ExoPlayer dutifully opens the content URI. Now the failure chain runs:

  1. The content URI is empty, so it has no scheme.
  2. In media3, Util.isLocalFileUri() returns true for any scheme-less URI — an empty URI looks exactly like a relative local path.
  3. So the load is routed to FileDataSource, which does new RandomAccessFile("", "r").
  4. That throws → PlaybackException with ERROR_CODE_IO_FILE_NOT_FOUND.
  5. With no onPlayerError handler registered, nothing catches it. The player stops, the surface is never repainted, and the screen stays black until someone power-cycles it.

Note the shape of this bug: there is no error on the ad path at all. Fills are fine, completions are fine, your programmatic-event reports are fine. The failure is entirely in content-resume, which is why it survives every ad-side check you might run.

The fix — two halves, and you need both.

(a) Give the player something real to resume into. Point content at your own loop/filler asset, or a 1-frame looping local file, so CONTENT_RESUME_REQUESTED has a valid destination:


// RIGHT — content resume has a real, playable destination
val contentUri = Uri.parse("asset:///filler_loop.mp4")   // any real, local, scheme-ful URI
val mediaItem = MediaItem.Builder()
    .setUri(contentUri)
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri)
            .setAdsId(source.requestId)
            .build()
    )
    .build()

If you genuinely have no content, use ImaAdsLoader's ad-only path (setPlayer + a content source you control) rather than an empty URI. The invariant is simply: never hand ExoPlayer a scheme-less or empty content URI.

(b) Always register an error handler (R4). This is the part that turns any future variant of this bug into a recoverable blip instead of a black screen:


player.addListener(object : Player.Listener {
    override fun onPlayerError(error: PlaybackException) {
        Log.e("TrillboardsAds", "player error ${error.errorCodeName}", error)
        // Do not stop here. Recover the SURFACE:
        showOwnContent()     // your playlist / house creative / static frame
        // and tell us, so the failure is visible on our side too:
        reportProgrammaticEvent(
            event = "error",
            requestId = currentSource.requestId,
            variantName = currentSource.name,
            errorCode = error.errorCodeName,
            errorMessage = error.message ?: "player error",
        )
        fireVastErrorBeacon(currentVastErrorUrl, error.errorCodeName)   // §7.1
    }
})

onPlayerError is not the same listener as IMA's AdErrorEvent.AdErrorListener. AdErrorListener covers ad load/play failures; onPlayerError covers everything ExoPlayer itself fails at, including content resume. Register both. The Lite SDK reference implementation does exactly this — ad errors go through onAdError, and the host gets a callback so it can repaint its own surface.


7. Reporting events back — POST /v1/partner/device/{deviceId}/programmatic-event

Fire these from your player lifecycle callbacks. You only need to report for the one source that actually played (the first accepted source in the waterfall). You do not need to report for sources that were skipped.

When IMA SDK fires STARTED


POST /v1/partner/device/{deviceId}/programmatic-event
Content-Type: application/json

{
  "event": "ad_started",
  "request_id": "req_1775716769503_eb810d042466",
  "variant_name": "adipolo",
  "creative_id": "<from IMA AdEvent.getAd().getCreativeId()>",
  "impression_id": "<from IMA AdEvent.getAd().getAdId() or generate UUID>"
}

When IMA SDK fires COMPLETE


POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "ad_ended",
  "request_id": "req_1775716769503_eb810d042466",
  "variant_name": "adipolo",
  "creative_id": "<same as ad_started>",
  "impression_id": "<same as ad_started>"
}

When the waterfall returns no ads


POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "no_fill",
  "request_id": "<request_id from the last source tried>",
  "variant_name": "<last variant name>"
}

When IMA SDK fires ERROR


POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "error",
  "request_id": "...",
  "variant_name": "...",
  "error_code": "<IMA error code>",
  "error_message": "<IMA error message>"
}

Required fields

You must include request_id and variant_name on every event. Events without these are silently dropped on our side.

7.1 The <Error> beacon — mandatory

programmatic-event is our HTTP report channel. The VAST <Error> element is the IAB-standard one, and it is not optional. Fire both.

Every VAST we serve carries an <Error> URL with the IAB [ERRORCODE] macro in it — on the programmatic wrap path, and, as of the signed-beacon change that shipped on the first-party direct rail, on trillboards_direct too. The URL is HMAC-signed by us (sig=), so it is only fireable as the beacon we minted:


<Error><![CDATA[https://api.trillboards.com/openrtb/v1/event?...&event=error&sig=...&errorCode=[ERRORCODE]]]></Error>

On any ad failure, substitute the numeric IMA/VAST error code for the literal [ERRORCODE] and issue a GET to that URL. IMA fires wrapper-level <Error> URLs for you when it can, but it cannot fire them for failures it never sees (your own fetch timeouts, your transport errors, a playback error you caught yourself) — so a custom or partly-custom player must fire them itself. Fire it once per failed source.


fun fireVastErrorBeacon(errorUrlFromVast: String?, errorCode: Any?) {
    val url = errorUrlFromVast ?: return
    val substituted = url.replace("[ERRORCODE]", Uri.encode(errorCode?.toString() ?: ""))
    // fire-and-forget GET; unauthenticated + idempotent, no response handling needed
    Thread { runCatching { URL(substituted).openStream().close() } }.start()
}

Why this matters more than it looks. On the first-party trillboards_direct rail the player is yours and we ship no agent onto it, so the <Error> beacon is the only ad-failure signal that exists for that rail. Without it we are structurally error-blind: our lifecycle rollups read error_count = 0 for self_promo / paid_direct not because nothing failed, but because nothing could report. That blindness is precisely what turned one partner's black screen into a full production forensics session — there was no signal to look at.

Notes on the URL:

The Lite SDK does all of this in one place — see onAdError in trillboards-lite.src.js: [ERRORCODE] substitution, keepalive GET (not sendBeacon — the endpoint is GET-only), then trackVastRequest and reportProgrammaticEvent. If you copy one function out of the reference implementation, copy that one.

Custom / non-IMA players: the <Error> URL is extracted the same way as the tracking URLs in §5b — add error: getUrls('Error') to parseVastTrackingUrls, and preserve <Error> at every wrapper level of the chain, exactly as you do for <Impression>.

Automatic tracking (no code required)

On top of programmatic-event, our server also receives impression events automatically via tracking pixels embedded in the VAST XML. When IMA SDK processes the VAST and fires the <Impression> tag, it hits https://api.trillboards.com/openrtb/v1/pixel?...&provider=adipolo automatically. You don't need to do anything for this — it happens because you passed the full unmodified VAST to IMA.

Both paths (programmatic-event and tracking pixels) are deduplicated on our side by request_id + impression_id.


8. Verification — how to tell it's working

Ask your Trillboards contact to run these:


# Live fills from your devices
aws logs tail /ecs/trillboards-api --since 5m | grep 'vast-wrap.*adipolo/<screenId>.*fill=true'

Expected output format:


[vast-wrap] adipolo/{screenId} client={device IP} fill=true type=wrapper vpaid=false pt=partner_api size=21378 204ms

fill=true means Adipolo returned a real ad creative. pt=partner_api confirms your integration path. size=21378 is a normal Adipolo wrapper response size — if it's much smaller the wrapper chain didn't resolve.

Completions arrive ~3-15 seconds after the fill. You'll know it's end-to-end working when you see completed_impressions rows with:

Do not use ingestion_path = 'impression_callback' as your success signal. That

path is guarded and deliberately excluded from the revenue rollup — rows carrying it

are not counted, so "I can see impression_callback rows" is not evidence that

anything settled. vast_event and proof_of_play are the billing-grade paths.

A booked first-party direct ad delivered on the data.ads rail settles under ingestion_path = 'owned_direct' instead, from the signed tracking.owned_direct_complete_url beacon on the ad object. Fire that URL verbatim — signature and all — and never rebuild it.


9. Common failure modes we've seen

"You never have any demand — every request is a no-fill"

You are almost certainly reading sources[0] and stopping. Programmatic DSPs no-fill constantly and legitimately (wrong geo, no bid, budget exhausted) — the waterfall exists precisely so the next source gets a turn, and our own guaranteed trillboards_direct creative often sits several indexes down. Walk every source by priority ascending, skip enabled: false, and advance on 303/300/301/1009. See §4.1 — this exact bug cost a partner a full production forensics session.

"The screen goes black after every ad"

Content resume, not the ad. Your content MediaItem almost certainly has an empty or scheme-less URI, so media3 routes it to FileDataSource and dies with ERROR_CODE_IO_FILE_NOT_FOUND the moment IMA fires CONTENT_RESUME_REQUESTED. Full chain and both halves of the fix: §6.1. The tell is that ad-side metrics look perfect — fills and completions are fine, because nothing on the ad path failed.

"Something is failing but you can't see anything on your side"

You are not firing the VAST <Error> beacon. On the first-party trillboards_direct rail it is the only ad-failure signal that exists — no beacon means our error counts read zero no matter how badly your player is failing. See §7.1.

"I'm getting fills but no completions"

Either:

  1. Your IMA SDK has setAdMediaMimeTypes("video/mp4") set — remove it.
  2. You're stripping the VAST wrapper before passing it to IMA — pass the full XML unmodified.
  3. Your device can't reach api.trillboards.com to fire the tracking pixels (firewall / DNS).

"My heartbeat sends device fields but you don't see them"

The heartbeat field must be device.ua (not device.uaSample or telemetry.ua). Our normalizer reads body.device.ua.

"Fill rate is 1% when our own Rockchip screens fill at 60%"

Your heartbeat identity is bad. Check:

"VidVerto specifically isn't completing"

VidVerto serves VPAID creatives. Native IMA SDK needs setAdMediaMimeTypes to be unset (or include application/javascript). This is the default — only override it if you're certain your player can only play mp4.

"I want to test the wire format without building a full SDK"

Append ?test=1 to your /ads call. That forces a guaranteed-fill test source into that one response — public alias src_test, a direct MP4 URL with no wrapper chain to resolve. No allowlist enrollment is needed, it's per-request only (real devices polling without the flag are unaffected), and the creative is non-revenue. If src_test completes but real partners don't, your VAST handling is fine and the problem is in the partner response path (probably MIME filter). If src_test doesn't complete, the bug is in your player — start with §4.1 and §6.1.


10. What's happening server-side (optional context)

When you GET a vast-wrap URL with pt=partner_api, our server does this:

  1. Fetches the upstream partner VAST (e.g., Adipolo's pubads.adipolo.co/ctv/ad?...) with your device's IP in X-Forwarded-For so DSPs geo-target correctly.
  2. Resolves wrapper chains up to 5 hops. Adipolo returns a wrapper pointing to GAM; we follow it and get the inline creative. Your device never sees the chain.
  3. Injects tracking pixels at the outermost wrapper level — <Impression> and <TrackingEvents> for start/quartile/complete. These include &provider={source_name} so attribution is baked in at injection time.
  4. Returns the final VAST XML (inline or resolved wrapper) with our pixels embedded.

You get back a VAST that's ready to hand straight to IMA SDK with no processing on your end.


11. Reference implementation

The canonical code: the Trillboards Lite SDK

trillboard-screen/public/partner-sdk/trillboards-lite.src.js is our own player, and it is the reference implementation for everything in §0. It's JavaScript, but the control flow is what you're copying, and it ports directly to Kotlin.

Read onAdError first. In one handler it does all of this, in this order:

  1. Pulls errorCode + errorMessage off the IMA AdErrorEvent.
  2. Classifies the failure — 1009 / 303 / a message containing no fill or no ads vast response is a no-fill; anything else is an error. (R2)
  3. Fires the VAST <Error> URL with [ERRORCODE] substituted for the real code, as a keepalive GET. (R5)
  4. Records the VAST request outcome (no_fill vs error) with latency and source name.
  5. Reports the programmatic-event with request_id, variant_name, error_code, error_message. (R6)
  6. Only resolves the caller when _pendingWaterfallSources is empty — while sources remain, it advances to the next one instead of finishing. (R1 + R2)
  7. Invokes a host callback so the embedding app can repaint its own surface. (R4)

Points 6 and 7 are the two that partners most often miss, and they're the two that produce the two worst failure modes: a waterfall that gives up at index 0, and a screen that stays black.

The reference deployment: Take 10 Media

Take 10 Media (com.take10media.adplayer) is the reference native Android integration. Key facts about their working setup:

Fill rate: 61% Adipolo. Completion rate: 79% of fills. Effective: ~48% of all ad requests result in billable completions.


12. Trillboards Sensing SDK (Android) — optional, alongside the IMA integration

Two artifacts cover two distinct hardware classes. Pick by what your screens actually have, NOT by what features you might want — installing the wrong one wastes bundle size and (for AGP <8) won't even link.

Which SDK matches your hardware?

Both ship sensing data over the same /openrtb/v1/heartbeat endpoint. After registration, liveness and targeting-identity heartbeats require no Partner API key; the stable registered device ID supplies the screen binding. The two SDKs are wire-format-compatible, so partners with a mixed fleet (some camera-equipped + some appliance) emit identically-shaped heartbeats from both.

Build compatibility matrix

ArtifactHardware profileMin AGPMin GradleMin JDKMin AndroidBundleJetifier OK
ctv-measurementCamera-less appliance (vending / ATM / charging / kiosk)4.16.5824 (Android 7.0)~3 MBYes
agent-core (full)Camera + mic equipped retail screen8.08.01726 (Android 8.0)~180 MBNo

Why the AGP gap? agent-core bundles CameraX 1.5.3, which embeds minAndroidGradlePluginVersion=8.0.0 in its AAR metadata — AGP 4.x consumers will hard-fail at checkDebugAarMetadata. ctv-measurement ships Java 1.8 bytecode (class v52) with no camera/ML deps, so AGP 4.1 + Jetifier (ASM 8, max class v58) can consume it cleanly.

What the backend does with the raw signals

Whichever SDK you install, you ship raw signals — Trillboards' audience resolver does all the math server-side:

All derived signals are attached to your bid requests automatically. You do not run any aggregation, ML, or schema-translation code client-side. Updating to a newer SDK version never requires backend coordination — the wire contract is stable.


12.A Camera-less appliances — ctv-measurement

For vending machines, ATMs, EV charging stations, self-checkout kiosks, smart lockers, and other Android appliance hardware without cameras or microphones.

Maven coordinate: com.trillboards:ctv-measurement:1.3.2

Registry: maven.trillboards.com (anonymous read, no auth tokens — the canonical Trillboards SDK Maven CDN).

Source mirror: https://github.com/trillboards/ctv-measurement-sdk (Apache 2.0).

12.A.1 Add the Maven registry

In your project's settings.gradle.kts (or root build.gradle.kts):


dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://maven.trillboards.com") }
    }
}

12.A.2 Add the dependency

In your app module's build.gradle.kts:


dependencies {
    implementation("com.trillboards:ctv-measurement:1.3.2")
}

The AAR is self-contained: BLE / WiFi / mDNS / SSDP / HTTP-probe / native-sensor scanners, plus a Rust-backed mDNS discovery layer (UniFFI bindings + per-ABI .so for arm64-v8a and armeabi-v7a). No camera, no microphone, no ML — works as-is on AGP 4.1+ + Jetifier.

12.A.3 Manifest permissions

Declare in your app's AndroidManifest.xml:


<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

<!-- BLE — API 31+ uses BLUETOOTH_SCAN + neverForLocation; older API falls back to ACCESS_FINE_LOCATION -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    android:maxSdkVersion="30" />

<!-- WiFi — API 33+ uses NEARBY_WIFI_DEVICES; older API uses ACCESS_WIFI_STATE -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
    android:usesPermissionFlags="neverForLocation" />

Both BLUETOOTH_SCAN and NEARBY_WIFI_DEVICES are runtime-dangerous — you must request each one at runtime. Declaring them (or letting the AAR's manifest merge in) is not a grant. Requesting only BLUETOOTH_SCAN yields BLE observations and a permanent zero on WiFi; requesting only NEARBY_WIFI_DEVICES yields the reverse. Below API 31 BLE falls back to ACCESS_FINE_LOCATION. **WiFi scanning needs ACCESS_FINE_LOCATION at every API level — including 33+, where it is required in addition to NEARBY_WIFI_DEVICES** — because WifiManager.startScan()/getScanResults() are the documented carve-out that NEARBY_WIFI_DEVICES does not cover. The AAR caps its own declaration at maxSdkVersion="30", so on API 31+ you must declare an uncapped ACCESS_FINE_LOCATION yourself. Copy the per-API-level requestPermissions snippet and the logcat verification from CTV Measurement SDK — Partner Integration Guide § Step 1b.

Radio observations remain bounded to the measurement contract. Targeting identities are separate: the SDK sends the real resettable GAID/AAID when the platform exposes one, and can send exact host-owned HEM/UID2 values. It never uses a screen or registered-device UUID as a MAID.

12.A.4 Initialize the SDK

Call once from Application.onCreate():


import com.trillboards.measurement.*

TrillboardsMeasurement.initialize(
    context = applicationContext,
    config = MeasurementConfig.Builder(
            deviceId = yourStableRegisteredDeviceId,
        )
        .scanIntervalMs(30_000L)
        .enabledSources(setOf(
            SignalSource.BLE, SignalSource.WIFI, SignalSource.MDNS,
            SignalSource.SSDP, SignalSource.HTTP_PROBE, SignalSource.NATIVE_SENSOR,
        ))
        .build(),
)
TrillboardsMeasurement.setConsentStatus(true)
TrillboardsMeasurement.startScheduledScans()

The SDK posts to /openrtb/v1/heartbeat on its own schedule without an API key. Use the same stable ID registered as the device's external_id. Call TrillboardsMeasurement.shutdown() if you need to wind sensing down (rare).

12.A.5 WebView bridge (optional)

If your ad creatives need to read cached snapshots from JavaScript:


TrillboardsMeasurement.attachToWebView(myWebView)
// Inside the ad creative:
//   window.TrillboardsMeasurement.getSnapshot()   → JSON string
//   window.TrillboardsMeasurement.getVersion()    → bridge version

Bridge must be attached before WebView.loadUrl(). The SDK holds the WebView via a WeakReference so there's no Activity/Fragment leak risk.


12.B Camera/mic-equipped retail screens — agent-core

For retail screens with audience-facing cameras and/or microphones (digital signage, in-store displays, kiosks with depth/RGB cameras). Face detection, attention estimation, audio classification, speech ASR, pose and VLM inference run on the screen. The agent sends Trillboards a record for each detected person, a face-identity template on the full sensing profile, speech transcripts, the Bluetooth, Wi-Fi and local-network identifiers of nearby devices, the screen's GPS position, and selected camera frames for cloud analysis.

Maven coordinate: com.trillboards:agent-core:1.14.0

Registry: maven.trillboards.com (anonymous read, no auth tokens — the canonical Trillboards SDK Maven CDN).

Source mirror: https://github.com/trillboards/agent-core (Apache 2.0).

12.B.1 Add the Maven registry

In your project's settings.gradle.kts (or root build.gradle.kts):


dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://maven.trillboards.com") }
    }
}

No credentials needed — maven.trillboards.com serves every Trillboards Sensing SDK release anonymously from the canonical Maven CDN. Pin an exact version (com.trillboards:agent-core:1.14.0) or float the minor line (com.trillboards:agent-core:1.14.+). The public source mirror lives at trillboards/agent-core.

12.B.2 Add the dependency

In your app module's build.gradle.kts:


dependencies {
    implementation("com.trillboards:agent-core:1.14.0")
}

The AAR pulls MediaPipe Tasks, TensorFlow Lite, CameraX, and Media3 transitively — face detection, audio classification, BLE/WiFi co-viewing, and demographic inference work out of the box. (sherpa-onnx ASR is opt-in via the BYO flow in §12.5b.)

Do not exclude transitive groups to disable a modality. agent-core hard-codes references to com.google.mediapipe.tasks.audio.*, com.google.mediapipe.tasks.vision.*, and friends; excluding the corresponding artifact at the Gradle level produces a NoClassDefFoundError at runtime the first time the agent tries to instantiate that processor — long after your CI passes. Use the SensingSdkConfig runtime flags in §12.4 instead:


TrillboardsSensingSdk.start(
    context = applicationContext,
    config = SensingSdkConfig(
        deviceId = yourStableRegisteredDeviceId,
        audioClassificationEnabled = false,  // mic-less screens
        speechIntelligenceEnabled = false,   // no ASR
        // …other modality flags
    )
)

This keeps the dep graph valid while skipping the processors at runtime — same effect, no crash risk.

12.B.3 Manifest permissions

Declare in your app's AndroidManifest.xml:


<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />        <!-- API 31+ -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />     <!-- API 31+, required for BLE GATT 0x180A device-info enrichment -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />  <!-- API <31 fallback for BLE; required for WiFi scanning at EVERY API level, incl. 33+ -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />  <!-- API 33+ WiFi scan -->
<uses-permission android:name="android.permission.INTERNET" />

Camera, microphone, BLUETOOTH_SCAN (API 31+), and NEARBY_WIFI_DEVICES (API 33+) are all runtime permissions — request every one of them via the standard Android prompt before starting the agent. The radio permissions are the easy ones to miss because they merge in from the AAR and never prompt on their own: skip the NEARBY_WIFI_DEVICES request and WiFi scanning returns nothing on Android 13+ while BLE keeps reporting normally. Use the per-API-level snippet in CTV Measurement SDK — Partner Integration Guide § Step 1b. The agent records no video and no audio; everything it sends is listed at the top of §12.B.

12.B.4 Initialize the SDK

Call TrillboardsSensingSdk.start() once — typically from MainActivity.onCreate() or Application.onCreate(). Pass the same stable device ID used during registration. Do not embed a Partner API key; the SDK resolves the registered screen and manages any privileged sensing-channel credential internally.


import com.trillboards.sdk.SensingSdkConfig
import com.trillboards.sdk.TrillboardsSensingSdk

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        TrillboardsSensingSdk.start(
            context = applicationContext,
            config = SensingSdkConfig(
                deviceId = yourStableRegisteredDeviceId
            )
        )
    }
}

Need to disable a modality (e.g., your screens have no microphone) or point at a non-prod environment? Pass a SensingSdkConfig:


TrillboardsSensingSdk.start(
    context = applicationContext,
    config = SensingSdkConfig(
        deviceId = yourStableRegisteredDeviceId,
        audioClassificationEnabled = false,
        speechIntelligenceEnabled = false,
        apiBaseUrl = "https://staging.api.trillboards.com",
        socketUrl = "https://staging.chat.trillboards.com"
    )
)

Call TrillboardsSensingSdk.stop(applicationContext) if you need to wind sensing down (rare — the SDK is designed to run continuously alongside your ad-serving loop).

That's the minimum integration. The agent handles camera initialization, model OTA download (~178 MB FaceXFormer + Moonshine, downloaded once on first run, then cached), heartbeat scheduling, and Socket.io reconnection internally.

12.B.5 What you get on the wire

The agent emits two channels:

  1. HTTP heartbeat — POST /openrtb/v1/heartbeat every 30s. Carries device health, capabilities, BLE/WiFi co-viewing arrays, the actual platform MAID when available, and typed host-provided HEM/UID2. The POST is unauthenticated after device registration; the device/screen binding provides attribution. Read live state with authenticated GET /v1/partner/device/{deviceId}/heartbeat (see API reference).
  2. Real-time signals — Socket.io to chat.trillboards.com, room joined automatically on start(). Events: audienceSignals (face/audio/speech detections), audienceDemographics (rolling demographic estimates), deviceCapabilities (model availability). These feed our backend for the audience-attention flywheel; you don't need to subscribe to them directly unless you want to mirror the data.

12.B.6 Speech recognition

agent-core 1.22 and later: included, nothing to add. On-device speech recognition (Moonshine ASR via sherpa-onnx) comes with the SDK. agent-core depends on com.trillboards.bundled:sherpa-onnx-static-ort:1.12.40, served from the same repository as agent-core, and carries the Moonshine model in its assets. Remove any sherpa-onnx-*.aar you added to libs/ for an earlier agent-core: two copies of com.k2fsa.sherpa.onnx on one classpath fail the build as duplicate classes.

Spanish transcription and spoken-language ID use a Whisper model that is delivered over the air to screens it is rolled out to; it is not in the APK.

Earlier agent-core versions: bring your own sherpa-onnx. There speech recognition is opt-in, because agent-core declares sherpa-onnx compileOnly. Face detection, audio classification, BLE/WiFi co-viewing and demographics work without any extra setup; only speech recognition is gated. To turn it on:

  1. Download sherpa-onnx-1.12.26.aar from the sherpa-onnx releases page (or contact engineering@trillboards.com for the Trillboards-bundled copy with the matching native libs).
  2. Drop it into your app's libs/ folder.
  3. Register a flatDir repository so Gradle can resolve it:

   // settings.gradle.kts
   dependencyResolutionManagement {
       repositories {
           flatDir { dirs(file("app/libs")) }
       }
   }
  1. Add the implementation dep:

   // app/build.gradle.kts
   dependencies {
       implementation(":sherpa-onnx-1.12.26@aar")
   }
  1. Bundle the Moonshine model assets under app/src/main/assets/moonshine-tiny/. The agent's runtime probe looks for moonshine-tiny/tokens.txt; if the file isn't present, speech recognition is silently skipped without affecting other modalities.

12.B.7 Reference implementation

com.trillboards:agent-core:1.14.0 ships the canonical Kotlin codebase that powers Trillboards' first-party retail screens. Source mirror lives at https://github.com/trillboards/agent-core (Apache 2.0).


12.C Cross-references (both SDKs)

The Sensing SDK page on the developer hub at https://trillboards.com/support/developers/sensing-sdk covers configuration tuning, what the SDK sends and how long Trillboards keeps it, model lifecycle, and capability tier defaults.


13. Support

When something breaks, share the following with Trillboards support:

  1. A screen_id that's actively requesting ads
  2. Your device's actual heartbeat body (the HTTP POST body, not a logcat sample)
  3. The full vast_waterfall object from one /ads response — the whole sources[] array, not just the entry you used — plus which indexes your player actually tried. This one line of evidence separates "no demand" from "your loop stopped at index 0" instantly.
  4. The VAST response you got back from vast-wrap for one request (save the raw XML)
  5. IMA SDK error codes if playback fails (from the AdErrorEvent callback) and any PlaybackException from onPlayerError — they are different listeners and different failure classes (§6.1)
  6. Your programmatic-event POST body for both ad_started and ad_ended for one ad

With these six artifacts we can trace the complete request lifecycle and tell you exactly what's broken.