# 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`, `rttMs` from
  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](https://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](https://api.trillboards.com/docs/integrations/ctv-measurement-quickstart.md)
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`:

```kotlin
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+) with `usesPermissionFlags="neverForLocation"`
  — BLE scanning
- `BLUETOOTH_CONNECT` (Android 12+) — optional GATT device-info enrichment
- `ACCESS_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 1b
- `NEARBY_WIFI_DEVICES` (Android 13+) with `usesPermissionFlags="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.**

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:

```xml
<!-- 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:

```kotlin
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
  requested `BLUETOOTH_SCAN` (or the user denied it).
- `Missing WiFi scan permission — skipping` → you never requested
  `NEARBY_WIFI_DEVICES` on API 33+ (or `ACCESS_FINE_LOCATION` below it).
- `WiFi scan failed: … SecurityException …` → **the two-gate miss.** On API 33+
  this is `NEARBY_WIFI_DEVICES` granted without `ACCESS_FINE_LOCATION`: our
  check passed, so the scan was attempted, and Android rejected it. Add the
  uncapped `ACCESS_FINE_LOCATION` declaration **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()`:

```kotlin
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:

```kotlin
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

```bash
npm install @trillboards/ctv-measurement
```

```typescript
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)

```html
<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:

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

```typescript
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:

```typescript
// 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.

## 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 cached `lastPayload` is immediately zeroed (no privacy leak
  via `getPayload()`).
- `setConsentStatus(true)` → measurement resumes.

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

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

## Cleanup

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

```kotlin
// 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 sets `allowRemoteConfig(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
