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
- BLE co-viewing measurement — Bluetooth proximity scanning. The Android library sends each nearby device's MAC address to Trillboards over TLS, and the SDK returns a normalized 0-1 proximity confidence score derived from RSSI.
- Device fingerprinting — Canvas, WebGL, and audio fingerprints combined into a stable per-device SHA-256 hash. No GAID dependency.
- Census demographic enrichment — IAB income/age segments and education tier derived from US Census ACS 2023 data at geohash-6 resolution. Server-side IP-based lookup.
- CTV viewability — MRC DOOH-compliant verdict (95% duration threshold) from IMA SDK lifecycle events.
- QR attribution — physical-to-digital attribution via IP co-location between the CTV screen and the mobile device that scanned the QR.
- Connection quality signals —
effectiveType,downlinkMbps,rttMsfrom the Network Information API. - Frequency & reach — server-side per-household-per-day counters and cached reach metrics returned in every impression response.
- Buyer-addressable identity — built-in MAID collection plus typed, exact HEM and UID2 heartbeat envelopes projected into OpenRTB.
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:
BLUETOOTH_SCAN(Android 12+) withusesPermissionFlags="neverForLocation"— BLE scanningBLUETOOTH_CONNECT(Android 12+) — optional GATT device-info enrichmentACCESS_FINE_LOCATION(maxSdkVersion="30") — the pre-Android-12 BLE gate. The cap is why WiFi scanning needs a manifest override from you on API 31+ — see Step 1bNEARBY_WIFI_DEVICES(Android 13+) withusesPermissionFlags="neverForLocation"— required for WiFi scanning on API 33+, but not sufficient on its own (Step 1b)ACCESS_WIFI_STATE,CHANGE_WIFI_STATE,CHANGE_WIFI_MULTICAST_STATE,INTERNET,ACCESS_NETWORK_STATE,AD_ID— normal permissions, granted at install, nothing to request
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 version | BLE scan needs | WiFi scan needs |
|---|---|---|
| API 33+ (13+) | BLUETOOTH_SCAN | NEARBY_WIFI_DEVICES and ACCESS_FINE_LOCATION † |
| API 31–32 (12 / 12L) | BLUETOOTH_SCAN | ACCESS_FINE_LOCATION † |
| API 30 and below (Android 11 and older) | ACCESS_FINE_LOCATION | ACCESS_FINE_LOCATION |
WiFi is the asymmetric one: on API 33+ it takes both permissions, because two different gates have to open.
- Our gate —
WifiScanCollectorchecksNEARBY_WIFI_DEVICESon API 33+ andACCESS_FINE_LOCATIONbelow it. Without the right one it logsMissing WiFi scan permission — skippingand never touches the radio. - Android's gate — the SDK collects via
WifiManager.startScan()/getScanResults(), and those two methods still requireACCESS_FINE_LOCATIONat 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 theACCESS_FINE_LOCATIONpermission, even when your app targets Android 13 or higher."*NEARBY_WIFI_DEVICESreplaces 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
Missing BLUETOOTH_SCAN permission — skipping (no perm grant)→ you never requestedBLUETOOTH_SCAN(or the user denied it).Missing WiFi scan permission — skipping→ you never requestedNEARBY_WIFI_DEVICESon API 33+ (orACCESS_FINE_LOCATIONbelow it).WiFi scan failed: … SecurityException …→ the two-gate miss. On API 33+ this isNEARBY_WIFI_DEVICESgranted withoutACCESS_FINE_LOCATION: our check passed, so the scan was attempted, and Android rejected it. Add the uncappedACCESS_FINE_LOCATIONdeclaration and request it at runtime. On API 31–32 the same line means the uncapped declaration is missing, so your runtime request had nothing to grant.
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:
- Reads the
tb_sid,tb_aid,tb_pidquery parameters - Calls
POST /v2/sdk/qr/scanto record the scan - Calls
POST /v2/sdk/qr/beaconwith 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.
GDPR / CCPA consent
The SDK is consent-first by default:
setConsentStatus(false)(the default) →measure()returns a zeroed payload,trackAdEvent()is a no-op, the BLE bridge listener stops storing snapshots, and any previously cachedlastPayloadis immediately zeroed (no privacy leak viagetPayload()).setConsentStatus(true)→ measurement resumes.
// 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
- No camera, no microphone — the SDK is fundamentally physical/network-based.
- No GAID dependency — works on devices with Limit Ad Tracking enabled.
- Radio identifiers and IP addresses — the Android library sends each nearby device's BLE MAC address and WiFi BSSID to Trillboards over TLS, and Trillboards stores them as received and as keyed hashes. Impression records store the request IP as an HMAC-SHA256 hash; a QR scan stores the scanning phone's IP address, and a phone whose IP matches the screen's is linked to that screen in the identity graph.
- Location — the heartbeat response turns the Android library's GPS capture on for every fleet by default (
locationEnabled: true) unless the app setsallowRemoteConfig(false). Capture runs wherever the app holds the location permission, and Trillboards stores the screen's GPS positions as a trail. - Census data is fully auditable — IAB segments derive deterministically from US Census ACS 2023 block-group data, not from probabilistic modeling.
- Consent gating is enforced in code, not policy — when consent is
false, the SDK is provably inert (verified by 110 unit tests + 60 Android unit tests).
Reference
- OpenAPI spec:
trillboard-api/docs/openapi/ctv-measurement-api.yaml - JavaScript SDK source:
trillboards-ctv-measurement/ - Android library source:
trillboard-ctv/trillboards-measurement-sdk/ - Server route source:
trillboard-api/routes/v2/sdk.js - Migration:
trillboard-api/postgres/migrations/20260407014944_create_sdk_impressions.sql - Publish workflow:
.github/workflows/publish-ctv-measurement.yml
Support
- Email: developers@trillboards.com