> ## Documentation Index
> Fetch the complete documentation index at: https://docs.permutive.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CTV Video Tracking

> Best practices for tracking video events across all CTV platforms

<CardGroup cols={3}>
  <Card title="Event Schema" href="#video-event-schema" icon="list" />

  <Card title="Implementation" href="#platform-implementation" icon="code" />

  <Card title="Best Practices" href="#best-practices" icon="check" />
</CardGroup>

## Overview

Consistent video event tracking across CTV platforms enables unified audience analytics and cross-platform cohort building. This guide covers the standard video event schema and implementation patterns for all supported platforms.

## Video Event Schema

Permutive uses a consistent video event schema across all CTV platforms:

### Core Events

| Event                 | Description                            | Trigger                                       |
| --------------------- | -------------------------------------- | --------------------------------------------- |
| **Videoview**         | User initiated video playback          | When video starts or user selects content     |
| **VideoEngagement**   | Internal SDK event — see callout below | Periodically during playback (on-device only) |
| **VideoCompletion**   | User finished watching                 | When video ends or user exits                 |
| **VideoAdView**       | Video ad started playing               | When ad playback begins                       |
| **VideoAdCompletion** | Video ad finished                      | When ad playback ends                         |
| **VideoAdClicked**    | User clicked video ad                  | When user interacts with ad                   |

### Standard Video Properties

Use these properties consistently across events for unified analytics:

<Info>
  **Property Naming:** The schema uses `snake_case` property names, nested under a top-level `video` parent. Each platform's SDK or addon handles input differently — see [Property Mapping](#property-mapping) below for the per-platform details.
</Info>

| Property                     | Type      | Description                                   |
| :--------------------------- | :-------- | :-------------------------------------------- |
| `video.title`                | string    | Video title                                   |
| `video.genre`                | string\[] | List of genres (e.g., \["Drama", "Thriller"]) |
| `video.content_type`         | string\[] | Content types (e.g., \["Series", "Episode"])  |
| `video.age_rating`           | string    | Age rating (e.g., "PG-13", "TV-MA")           |
| `video.runtime`              | number    | Runtime in seconds                            |
| `video.country`              | string    | Origin country                                |
| `video.original_language`    | string    | Original language code                        |
| `video.audio_language`       | string    | Audio language being watched                  |
| `video.subtitles.enabled`    | boolean   | Whether subtitles are enabled                 |
| `video.subtitles.language`   | string    | Subtitle language                             |
| `video.season_number`        | number    | Season number (for series)                    |
| `video.episode_number`       | number    | Episode number (for series)                   |
| `video.consecutive_episodes` | number    | Consecutive episodes watched                  |
| `video.iab_categories`       | string\[] | IAB content taxonomy categories               |

### Completion Event Properties

Additional properties on `VideoCompletion` (aggregated from `VideoEngagement` data accumulated during playback):

| Property                                    | Type   | Description                    |
| :------------------------------------------ | :----- | :----------------------------- |
| `aggregations.VideoEngagement.completion`   | number | Percentage watched (0.0 - 1.0) |
| `aggregations.VideoEngagement.engaged_time` | number | Time spent watching in seconds |

### Ad Event Properties

Properties for video ad events (`VideoAdView`, `VideoAdCompletion`, `VideoAdClicked`):

| Property         | Type    | Description              |
| :--------------- | :------ | :----------------------- |
| `ad.title`       | string  | Ad title                 |
| `ad.duration`    | number  | Ad duration in seconds   |
| `ad.muted`       | boolean | Whether the ad was muted |
| `ad.campaign_id` | string  | Campaign identifier      |
| `ad.creative_id` | string  | Creative identifier      |

### Ad Completion Event Properties

Additional properties on `VideoAdCompletion`:

| Property                                      | Type   | Description                           |
| :-------------------------------------------- | :----- | :------------------------------------ |
| `aggregations.VideoAdEngagement.completion`   | number | Percentage of ad watched (0.0 - 1.0)  |
| `aggregations.VideoAdEngagement.engaged_time` | number | Time spent watching the ad in seconds |

***

## Platform Implementation

<Tabs>
  <Tab title="Web CTV">
    **Platforms:** Samsung Tizen, LG WebOS, HbbTV

    Use the CTV addon for automatic event tracking:

    ```javascript theme={"dark"}
    // Initialize with video properties
    permutive.addon("ctv", {
      duration: 3600000, // milliseconds
      videoProperties: {
        title: "Show Title - S1E1",
        genre: ["Drama", "Thriller"],
        season_number: 1,
        episode_number: 1,
        runtime: 3600
      }
    })

    // Sync with player events
    videoElement.addEventListener("play", () => {
      permutive.addons.ctv.play(videoElement.currentTime * 1000)
    })

    videoElement.addEventListener("pause", () => {
      permutive.addons.ctv.pause(videoElement.currentTime * 1000)
    })

    // Track completion
    const onVideoEnd = () => {
      permutive.addons.ctv.stop(videoElement.currentTime * 1000)
    }

    // Track ads manually
    const onAdStart = (ad) => {
      permutive.addons.ctv.track("VideoAdView", {
        ad: {
          title: ad.title,
          duration: ad.duration,
          muted: ad.muted,
          campaign_id: ad.campaignId,
          creative_id: ad.creativeId
        }
      })
    }
    ```

    **Automatic Events:**

    * `Videoview` - tracked when addon is initialized
    * `VideoCompletion` - tracked when `.stop()` is called

    See [Web CTV documentation](/sdks/ctv/web-ctv#ctv-addon) for full details.
  </Tab>

  <Tab title="tvOS">
    **Platform:** tvOS

    Create a video tracker via `createVideoTracker(duration:properties:context:)`:

    ```swift theme={"dark"}
    import Permutive_iOS

    // The iOS SDK does not auto-nest publisher properties under `video` like
    // Android does — hand-build the nested EventProperties dictionary so the
    // emitted event matches the schema (`video.title`, `video.content_type`, ...).
    let videoProps = try? EventProperties([
        "video": [
            "title": "Show Title - S1E1",
            "genre": ["Drama", "Thriller"],
            "content_type": ["Series", "Episode"]
        ]
    ])

    let context = Context(
        title: "Now Playing",
        url: URL(string: "https://example.com/show/s1e1")
    )

    // `duration` is a TimeInterval (seconds), not milliseconds.
    mediaTracker = try? Permutive.shared.createVideoTracker(
        duration: videoDurationSeconds,
        properties: videoProps,
        context: context
    )

    // Sync with player
    player.observe(\.timeControlStatus) { player, _ in
        switch player.timeControlStatus {
        case .playing:
            mediaTracker?.play()
        case .paused:
            mediaTracker?.pause()
        default:
            break
        }
    }

    // Track completion
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        mediaTracker?.stop()
    }
    ```

    **Automatic Events:**

    * `Videoview` - tracked on first `play()` call
    * `VideoCompletion` - tracked on `stop()` call

    Pause stops the engagement timer but does not emit an event.

    See [tvOS documentation](/sdks/ctv/tvos#video-tracking) for full details.
  </Tab>

  <Tab title="Android TV">
    **Platforms:** Android TV, Google TV

    Use MediaTracker for video tracking:

    ```kotlin theme={"dark"}
    import com.permutive.android.MediaTracker

    // Create MediaTracker when video starts
    videoTracker = permutive.trackVideoView(
        durationMilliseconds = videoDurationMs,
        videoProperties = MediaTracker.VideoProperties(
            title = "Show Title - S1E1",
            genre = listOf("Drama", "Thriller"),
            contentType = listOf("Series", "Episode"),
            seasonNumber = 1,
            episodeNumber = 1
        ),
        pageProperties = MediaTracker.PageProperties(
            title = "Now Playing",
            url = Uri.parse("https://example.com/show/s1e1")
        )
    )

    // Sync with ExoPlayer
    exoPlayer.addListener(object : Player.Listener {
        override fun onIsPlayingChanged(isPlaying: Boolean) {
            if (isPlaying) {
                videoTracker.play()
            } else {
                videoTracker.pause()
            }
        }
    })

    // Track completion
    override fun onDestroy() {
        super.onDestroy()
        videoTracker.stop()
    }
    ```

    **Automatic Events:**

    * `Videoview` - tracked when MediaTracker is created
    * `VideoCompletion` - tracked on `stop()` call

    See [Android TV documentation](/sdks/ctv/android-tv#video-tracking) for full details.
  </Tab>

  <Tab title="Roku">
    **Platform:** Roku

    Track events manually through the Permutive Task:

    ```brightscript theme={"dark"}
    ' Generate view ID for this content session
    m.global.permutive.view_id = CreateObject("roDeviceInfo").GetRandomUUID()

    ' Track video start
    m.global.permutive.event = {
        name: "Videoview"
        properties: {
            video: {
                title: m.video.title
                genre: m.video.genres
                season_number: m.video.season
                episode_number: m.video.episode
                runtime: m.video.duration
            }
        }
    }

    ' Track video completion
    sub onVideoComplete()
        m.global.permutive.event = {
            name: "VideoCompletion"
            properties: {
                video: {
                    title: m.video.title
                }
                aggregations: {
                    VideoEngagement: {
                        completion: m.player.position / m.player.duration
                        engaged_time: m.engagedSeconds
                    }
                }
            }
        }
    end sub

    ' Track ad events
    sub onAdStart(ad as Object)
        m.global.permutive.event = {
            name: "VideoAdView"
            properties: {
                ad: {
                    title: ad.title
                    duration: ad.duration
                    muted: ad.muted
                    campaign_id: ad.campaignId
                    creative_id: ad.creativeId
                }
            }
        }
    end sub
    ```

    **Manual Events:** All events must be tracked explicitly on Roku.

    See [Roku documentation](/sdks/ctv/roku#event-tracking) for full details.
  </Tab>
</Tabs>

***

## Engagement Tracking

### How Engagement Works

Engagement time measures how long a user actively watches content:

1. **Start** - When `play()` is called, engagement timer starts
2. **Pause** - When `pause()` is called, timer pauses
3. **Resume** - When `play()` is called again, timer resumes
4. **Complete** - When `stop()` is called, total engagement is recorded

### Buffering Considerations

Buffering should pause engagement tracking:

<Tabs>
  <Tab title="Web CTV">
    ```javascript theme={"dark"}
    videoElement.addEventListener("waiting", () => {
      permutive.addons.ctv.pause()
    })

    videoElement.addEventListener("playing", () => {
      permutive.addons.ctv.play()
    })
    ```
  </Tab>

  <Tab title="Native (iOS/Android)">
    ```kotlin theme={"dark"}
    // Android example
    exoPlayer.addListener(object : Player.Listener {
        override fun onPlaybackStateChanged(state: Int) {
            when (state) {
                Player.STATE_BUFFERING -> videoTracker.pause()
                Player.STATE_READY -> {
                    if (exoPlayer.isPlaying) videoTracker.play()
                }
            }
        }
    })
    ```
  </Tab>
</Tabs>

### Seeking/Scrubbing

When users seek to a new position:

```javascript theme={"dark"}
// Web CTV
videoElement.addEventListener("seeked", () => {
  permutive.addons.ctv.play(videoElement.currentTime * 1000)
})
```

```kotlin theme={"dark"}
// Android
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
    override fun onPositionDiscontinuity(
        eventTime: AnalyticsListener.EventTime,
        reason: Int
    ) {
        if (reason == Player.DISCONTINUITY_REASON_SEEK) {
            videoTracker.play(exoPlayer.currentPosition)
        }
    }
})
```

***

## Ad Break Handling

### Pre-roll Ads

Track ads before main content:

```javascript theme={"dark"}
// Web CTV example
const onPrerollStart = (ad) => {
  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration,
      muted: ad.muted,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })
}

const onPrerollEnd = (ad) => {
  permutive.addons.ctv.track("VideoAdCompletion", {
    ad: {
      title: ad.title,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })

  // Now start main content
  permutive.addon("ctv", {
    duration: videoDuration,
    videoProperties: { title: "Show Title" }
  })
  permutive.addons.ctv.play()
}
```

### Mid-roll Ads

Pause main content tracking during ads:

```javascript theme={"dark"}
const onMidrollStart = (ad) => {
  // Pause main content tracking
  permutive.addons.ctv.pause()

  // Track ad
  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration,
      muted: ad.muted,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })
}

const onMidrollEnd = (ad) => {
  permutive.addons.ctv.track("VideoAdCompletion", {
    ad: {
      title: ad.title,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })

  // Resume main content tracking
  permutive.addons.ctv.play()
}
```

### Post-roll Ads

Track ads after main content completes:

```javascript theme={"dark"}
const onContentEnd = () => {
  // Complete main content tracking first
  permutive.addons.ctv.stop()
}

const onPostrollStart = (ad) => {
  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration,
      muted: ad.muted,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })
}
```

***

## Best Practices

<Tabs>
  <Tab title="Do">
    * **Set duration accurately** - Provide video duration in milliseconds when initializing
    * **Sync with player state** - Call `play()`/`pause()` when the player state changes
    * **Always call stop()** - Ensure `stop()` is called when video ends or user exits
    * **Handle buffering** - Pause tracking during buffering states
    * **Track all ad events** - Capture ad views and completions for ad analytics
    * **Use consistent properties** - Follow the standard schema across all platforms
    * **Include video metadata** - Richer metadata enables more precise cohort building
    * **Generate unique view IDs** - Create new view IDs per content session (Roku)
  </Tab>

  <Tab title="Don't">
    * Don't forget to call `stop()` when done
    * Don't create multiple trackers simultaneously
    * Don't skip duration - completion percentage requires it
    * Don't track engagement during ads (pause main tracker)
    * Don't use inconsistent property names across platforms
    * Don't send PII in video properties
    * Don't guess at video duration - wait for it to be available
  </Tab>
</Tabs>

***

## Cross-Platform Consistency

For unified analytics across platforms, ensure:

### Event Name Consistency

| Platform   | Videoview Event                     | Completion Event                          | Ad View Event                           |
| ---------- | ----------------------------------- | ----------------------------------------- | --------------------------------------- |
| Web CTV    | `Videoview` (auto via addon)        | `VideoCompletion` (auto via addon)        | `VideoAdView` (manual via `.track()`)   |
| tvOS       | `Videoview` (auto via MediaTracker) | `VideoCompletion` (auto via MediaTracker) | `VideoAdView` (manual via `track()`)    |
| Android TV | `Videoview` (auto via MediaTracker) | `VideoCompletion` (auto via MediaTracker) | `VideoAdView` (auto via VideoAdTracker) |
| Roku       | `Videoview` (manual)                | `VideoCompletion` (manual)                | `VideoAdView` (manual)                  |

### Property Mapping

Each platform exposes a different publisher input shape, but all platforms ultimately produce events that conform to the schema above (properties nested under a top-level `video` parent).

| Platform        | Publisher input shape                                                                                            | Mapping behavior                                                                             |
| --------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Web CTV (addon) | Flat `videoProperties: { title, content_type, ... }` in `snake_case`                                             | Addon auto-nests publisher properties under `video`                                          |
| Android SDK     | Typed `MediaTracker.VideoProperties(title, contentType, ...)` in `camelCase`                                     | SDK maps `camelCase` → `snake_case` and nests under `video`                                  |
| iOS SDK         | Untyped `EventProperties` dictionary; publisher must hand-build `["video": ["title": ..., "content_type": ...]]` | No auto-mapping or auto-nesting — publishers write `snake_case` directly under a `video` key |
| Roku            | Manually constructed event dict with `properties: { video: { ... } }`                                            | None — publisher controls the full event shape                                               |

<Info>
  Work with [Technical Services](mailto:technical-services@permutive.com) to configure a unified event schema across all platforms.
</Info>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Engagement time is incorrect">
    **Problem:** Engaged time doesn't match expected values.

    **Solutions:**

    * Ensure `play()` is called only when video is actually playing
    * Call `pause()` during buffering and paused states
    * Verify `stop()` is called when video ends
    * Check that ad breaks pause the main content tracker
  </Accordion>

  <Accordion title="Completion percentage is 0 or missing">
    **Problem:** VideoCompletion event shows 0% or null completion.

    **Solutions:**

    * Provide duration when creating the tracker/addon
    * Ensure duration is in the correct unit (milliseconds for most SDKs)
    * Wait for video metadata to load before creating tracker
  </Accordion>

  <Accordion title="Video events not appearing in dashboard">
    **Problem:** Events don't show in Permutive analytics.

    **Solutions:**

    * Verify SDK is initialized with correct credentials
    * Check browser/device console for errors
    * Ensure events match your workspace schema
    * Wait 5-10 minutes for events to process
  </Accordion>

  <Accordion title="Ad events not linked to video sessions">
    **Problem:** Ad events aren't associated with video content.

    **Solutions:**

    * Track ads using the same addon/tracker instance
    * On Roku, use the same `view_id` for content and ads
    * Ensure ad events fire during the video session
  </Accordion>
</AccordionGroup>

***

## Related Documentation

<CardGroup cols={2}>
  <Card title="CTV Overview" icon="tv" href="/sdks/ctv/overview">
    Platform selection guide
  </Card>

  <Card title="Web CTV" icon="browser" href="/sdks/ctv/web-ctv">
    Tizen, WebOS, HbbTV integration
  </Card>

  <Card title="tvOS" icon="apple" href="/sdks/ctv/tvos">
    tvOS integration
  </Card>

  <Card title="Android TV" icon="android" href="/sdks/ctv/android-tv">
    Android TV integration
  </Card>

  <Card title="Roku" icon="tv" href="/sdks/ctv/roku">
    Roku integration
  </Card>

  <Card title="iOS Video Tracking" icon="video" href="/sdks/mobile/ios/features/video-tracking">
    Detailed iOS MediaTracker docs
  </Card>
</CardGroup>
