CTV Measurement SDK — Partner Integration Guide

For CTV publishers integrating @trillboards/ctv-measurement (JavaScript) and trillboards-measurement-sdk (Android AAR) into their Android TV / Fire TV apps.

What you get

Getting an API key

The native Android heartbeat does not require an API key after the device has been registered. Initialize it with the same stable device ID used during registration; that device/screen binding is the heartbeat trust boundary.

The web SDK's /v2/sdk/* enrichment and attribution endpoints authenticate with your Trillboards Partner API key (trb_partner_* / tb_prt_*) — the same key you use for the rest of the Partner API. Register once via the self-serve POST /v1/partner/register flow (or the web form at trillboards.com/partners/get-started) and the key you get back already carries the sdk:* scope (sdk:enrich, sdk:qr, sdk:impression), so it authenticates against /v2/sdk/* immediately — no separate CTV key, no email round-trip to unlock scopes.

See the Quickstart for the unauthenticated registered-device heartbeat contract.

Architecture


┌──────────────────────────────────────────────┐
│  CTV Device (Fire TV / Android TV)           │
│                                              │
│  ┌────────────────────────┐                  │
│  │  Publisher Android App │                  │
│  │                        │                  │
│  │  ┌──────────────────┐  │                  │
│  │  │ trillboards-     │  │ ◄─── BLE scans   │
│  │  │ measurement-sdk  │  │                  │
│  │  │ (AAR, ~44KB)     │  │                  │
│  │  └────────┬─────────┘  │                  │
│  │           │  bridge    │                  │
│  │           ▼            │                  │
│  │  ┌──────────────────┐  │                  │
│  │  │     WebView      │  │                  │
│  │  │                  │  │                  │
│  │  │ @trillboards/    │  │ ◄── fingerprint  │
│  │  │ ctv-measurement  │  │ ◄── viewability  │
│  │  │ (JS, ~12KB IIFE) │──┼─►  POST /v2/sdk/*│
│  │  └──────────────────┘  │                  │
│  └────────────────────────┘                  │
└──────────────────────────────────────────────┘
                  │
                  ▼
       https://api.trillboards.com

The Android library scans BLE on the native side and pushes snapshots into the WebView via window.dispatchEvent(new CustomEvent('trillboards:measurement')). The JS SDK listens for that event, combines BLE data with browser-side fingerprint/viewability/connection signals, calls /v2/sdk/enrich for census data, and sends the unified payload to /v2/sdk/impression.

Step 1 — Add the Android library

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


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

The library declares its own permissions in AndroidManifest.xml, and manifest merge pulls them into your app automatically — you do not copy them:

Step 1b — Runtime permissions: request BOTH, or you get half the signals

Manifest merge is not a grant. BLUETOOTH_SCAN, NEARBY_WIFI_DEVICES and ACCESS_FINE_LOCATION are all runtime-dangerous permissions, so your host app must request each one it needs at runtime. Skipping a request fails silently: the radio you did grant keeps reporting normally and the other returns nothing, forever. It does not look like a broken integration — observations are arriving, just half of them. We see both halves of this on live partner fleets today, which is why this section exists.

What each radio needs granted, by the API level of the device:

Android versionBLE scan needsWiFi scan needs
API 33+ (13+)BLUETOOTH_SCANNEARBY_WIFI_DEVICES and ACCESS_FINE_LOCATION †
API 31–32 (12 / 12L)BLUETOOTH_SCANACCESS_FINE_LOCATION †
API 30 and below (Android 11 and older)ACCESS_FINE_LOCATIONACCESS_FINE_LOCATION

WiFi is the asymmetric one: on API 33+ it takes both permissions, because two different gates have to open.

  1. Our gate — WifiScanCollector checks NEARBY_WIFI_DEVICES on API 33+ and ACCESS_FINE_LOCATION below it. Without the right one it logs Missing WiFi scan permission — skipping and never touches the radio.
  2. Android's gate — the SDK collects via WifiManager.startScan() / getScanResults(), and those two methods still require ACCESS_FINE_LOCATION at every API level, including 33+. This is the documented carve-out in [Request permission to access nearby Wi-Fi devices](https://developer.android.com/develop/connectivity/wifi/wifi-permissions#previous-versions): *"Several Wi-Fi APIs require the ACCESS_FINE_LOCATION permission, even when your app targets Android 13 or higher."* NEARBY_WIFI_DEVICES replaces location for Wi-Fi Aware, Wi-Fi Direct, RTT and local-only hotspot — **not for scanning**, which is why holding it alone is not enough.

Hold only NEARBY_WIFI_DEVICES on API 33+ and you land between the two gates: ours opens, Android's does not, and getScanResults() throws a SecurityException the collector catches as WiFi scan failed. That reads as a working integration reporting zero WiFi forever.

† Both rows need an uncapped ACCESS_FINE_LOCATION from your manifest, because the AAR caps its own at maxSdkVersion="30" on purpose — an uncapped location declaration would force a Google Play location-permission declaration on every radio-only integration. So on any device at API 31 or above, if you want WiFi scanning, add the uncapped declaration to your manifest (your app's declaration wins the merge) and request it at runtime:


<!-- your <manifest> element needs xmlns:tools="http://schemas.android.com/tools" -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    tools:node="replace" />

Request both radios from your Activity before (or any time after) starting scans — the SDK re-checks the grant on every scan, so a grant that lands late is picked up on the next scan interval with no re-initialization:


import android.Manifest
import android.content.pm.PackageManager
import android.os.Build

private const val REQ_TRILLBOARDS_MEASUREMENT = 4711

/** Everything the measurement SDK needs a runtime grant for, per API level. */
private fun trillboardsMeasurementPermissions(): Array<String> {
    val perms = mutableListOf<String>()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        // API 31+ — BLE scanning. Declared neverForLocation, but still
        // dangerous-tier: no runtime grant means zero BLE observations.
        perms.add(Manifest.permission.BLUETOOTH_SCAN)
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        // API 33+ — WiFi scanning. This is the one that gets forgotten.
        perms.add(Manifest.permission.NEARBY_WIFI_DEVICES)
    }
    // ACCESS_FINE_LOCATION at EVERY level, and note this is not an `else`:
    //  - API 33+  — NEARBY_WIFI_DEVICES opens our gate, but startScan()/
    //               getScanResults() still require location. Both, or no WiFi.
    //  - API 31-32 — the WiFi scan gate.
    //  - API <=30  — the WiFi *and* BLE scan gate.
    // On API 31+ this ALSO needs the uncapped manifest override above, or the
    // request silently no-ops against the AAR's maxSdkVersion="30" declaration.
    perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
    return perms.toTypedArray()
}

// In your Activity (e.g. onCreate, after your CMP returns granted=true):
val needed = trillboardsMeasurementPermissions()
    .filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
    .toTypedArray()
if (needed.isNotEmpty()) {
    requestPermissions(needed, REQ_TRILLBOARDS_MEASUREMENT)
}

On AndroidX you can use registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) instead — the permission list is identical.

Verify it worked

Check both radios independently. A lopsided result is a missing runtime grant, not throttling and not a scan-interval problem — the OS throttles WiFi scans to a slower cadence, but throttling never produces a permanent zero on one radio while the other reports normally.


adb logcat -s BleBeaconScanner:W WifiScanCollector:D

Server-side, the same tell shows up as a screen reporting a healthy stream of BLE observations with zero WiFi observations (or the reverse) across every day it has been live. If you see that on your fleet, fix the runtime request — no backend change will recover the missing radio.

Step 2 — Initialize the Android library

In your Application.onCreate() or main Activity.onCreate():


import com.trillboards.measurement.TrillboardsMeasurement
import com.trillboards.measurement.MeasurementConfig

// Initialize once per app process
TrillboardsMeasurement.initialize(
    applicationContext,
    MeasurementConfig.Builder("your-registered-device-id")
        .scanIntervalMs(30_000)   // 30s between scans (default)
        .scanDurationMs(15_000)   // 15s scan window (default)
        .debugLogging(false)
        .build()
)

// After your CMP returns granted=true
TrillboardsMeasurement.setConsentStatus(true)

// Start periodic BLE scans
TrillboardsMeasurement.startScheduledScans()

Critical: setConsentStatus(true) must be called AFTER obtaining GDPR/CCPA consent. The default consent state is false and scan() / startScheduledScans() are no-ops until consent is granted. The library caches no data and emits no events while consent is denied.

Step 3 — Attach the bridge to your WebView

Before loading your ad creative URL:


val webView = findViewById<WebView>(R.id.ad_webview)
webView.settings.javaScriptEnabled = true

// Attach BEFORE loadUrl() so the JS bridge is available on first script execution
TrillboardsMeasurement.attachToWebView(webView)

webView.loadUrl("https://your-ad-creative.example.com")

The library exposes window.TrillboardsMeasurement.getSnapshot() (pull mode) and dispatches window.dispatchEvent(new CustomEvent('trillboards:measurement', { detail: ... })) on each scan (push mode). The JS SDK handles both automatically.

Step 4 — Add the JavaScript SDK to your ad creative

Option A: npm


npm install @trillboards/ctv-measurement

import { CtvMeasurement } from '@trillboards/ctv-measurement';

const sdk = await CtvMeasurement.create({
  apiKey: 'trb_partner_your_api_key_here',
  screenId: 'screen-123',  // your internal screen identifier
  debug: false,
});

// After your CMP returns granted=true
sdk.setConsentStatus(true);

Option B: <script> tag (IIFE bundle)


<script src="https://cdn.trillboards.com/sdk/trillboards-ctv-measurement.global.js"></script>
<script>
  TrillboardsCtvMeasurement.create({
    apiKey: 'trb_partner_your_api_key_here',
    screenId: 'screen-123',
  }).then(sdk => {
    sdk.setConsentStatus(true);
    // ... use sdk
  });
</script>

The IIFE bundle is ~12 KB minified, zero runtime dependencies.

Step 5 — Track ad lifecycle events

Hook the SDK into your IMA SDK / VAST player events:


adsManager.addEventListener(google.ima.AdEvent.Type.IMPRESSION, () => {
  sdk.trackAdEvent('impression', {
    adId: currentAd.getAdId(),
    placementId: 'preroll-1',
    scheduledDurationMs: currentAd.getDuration() * 1000,
  });
});

adsManager.addEventListener(google.ima.AdEvent.Type.STARTED, () => {
  sdk.trackAdEvent('start', { adId, placementId: 'preroll-1' });
});

adsManager.addEventListener(google.ima.AdEvent.Type.FIRST_QUARTILE, () => {
  sdk.trackAdEvent('firstQuartile', { adId, placementId: 'preroll-1' });
});

adsManager.addEventListener(google.ima.AdEvent.Type.MIDPOINT, () => {
  sdk.trackAdEvent('midpoint', { adId, placementId: 'preroll-1' });
});

adsManager.addEventListener(google.ima.AdEvent.Type.THIRD_QUARTILE, () => {
  sdk.trackAdEvent('thirdQuartile', { adId, placementId: 'preroll-1' });
});

adsManager.addEventListener(google.ima.AdEvent.Type.COMPLETE, () => {
  sdk.trackAdEvent('complete', {
    adId,
    placementId: 'preroll-1',
    actualDurationMs: currentAd.getDuration() * 1000,
  });
});

Step 6 — Measure and ingest the impression

After the ad completes (or at any point you want to record the impression):


const payload = await sdk.measure();
// {
//   schema_version: '0.1',
//   sdk_version: '0.1.0',
//   sessionId: 'uuid',
//   impressionId: 'uuid',
//   timestamp: '2026-04-07T...',
//   bleDeviceCount: 3,
//   bleProximityConfidence: 0.78,
//   bleTopDeviceRssi: -55,
//   householdId: 'hashed-ip',
//   deviceFingerprint: 'sha256-hex',
//   canvasHash: 'sha256-hex',
//   webglRenderer: 'ANGLE...',
//   webglVendor: 'Google Inc.',
//   audioSampleRate: 48000,
//   iabIncomeSegmentId: '1002',
//   iabIncomeSegmentName: 'Upper-Middle Income ($100-150k)',
//   iabAgeSegmentId: '2003',
//   iabAgeSegmentName: 'Age 35-44',
//   educationTier: 'bachelors_plus',
//   connectionType: 'residential',
//   effectiveType: '4g',
//   downlinkMbps: 50.0,
//   rttMs: 25,
//   viewable: true,
//   viewabilityReason: 'ctv_full_completion',
//   completionRatio: 1.0,
//   imaImpressionConfirmed: true,
//   qrScanId: null,
//   qrBeaconSent: false
// }

// Send the payload to /v2/sdk/impression
const res = await fetch('https://api.trillboards.com/v2/sdk/impression', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': 'trb_partner_your_api_key_here',
  },
  body: JSON.stringify(payload),
});
const result = await res.json();
// {
//   received: true,
//   impressionId: 'uuid',
//   givtPassed: true,
//   frequency: 3,           // 3rd time this household has seen this ad today
//   householdReach: 1247,    // distinct households for this ad (cached, 5min TTL)
//   publisherReach: 15832    // distinct households across all your ads
// }

QR attribution flow

To enable physical-to-digital attribution, render a QR code in your ad creative:


// Generate a tracking URL pointing at your destination page
const qrUrl = sdk.getQrTrackingUrl(
  'https://shop.example.com/landing',
  'ad-1',
  'placement-1'
);
// => https://shop.example.com/landing?tb_sid=<scanId>&tb_aid=ad-1&tb_pid=placement-1

// Render the QR with your library of choice (e.g. qrcode-svg, qr.js)
renderQrCode(qrUrl, document.getElementById('qr-container'));

When a mobile user scans the QR, they land on your destination page. Your landing page needs a small beacon script that:

  1. Reads the tb_sid, tb_aid, tb_pid query parameters
  2. Calls POST /v2/sdk/qr/scan to record the scan
  3. Calls POST /v2/sdk/qr/beacon with browser fingerprint signals

The Trillboards backend then performs IP co-location matching: if the mobile beacon's IP hash matches the CTV screen's recently observed IP hash, an identity graph edge is created and ipMatch: true is returned.

The next sdk.measure() call after getQrTrackingUrl() automatically includes the tb_sid value as qrScanId in the payload, so the impression can be correlated with the scan event server-side.

The SDK is consent-first by default:


// User denies consent
sdk.setConsentStatus(false);
// All subsequent measure() calls return zeroed payloads

// User grants consent
sdk.setConsentStatus(true);
// Measurement resumes

Cleanup


// On page unload or component unmount
sdk.destroy();

// On Application terminate (rare — usually you want the SDK to live
// for the entire app lifetime)
TrillboardsMeasurement.shutdown()

Privacy & compliance

Reference

Support