> ## 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.

# Video Tracking

> Track video content viewing with the Permutive JavaScript SDK using the CTV addon

Track video engagement to build video-based cohorts and understand viewing behavior.

<CardGroup cols={3}>
  <Card title="Initialization" href="#initializing-the-ctv-addon" icon="play" />

  <Card title="Player Lifecycle" href="#player-lifecycle" icon="video" />

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

## Overview

Video tracking in the Permutive JavaScript SDK is handled by the **CTV addon**. The addon manages engagement timing automatically and tracks the standard Permutive video events — `Videoview` and `VideoCompletion` — without requiring manual interval logic or custom event construction.

The CTV addon is the correct approach for video tracking on:

* Standard web pages with embedded video players
* Web-based CTV applications (Samsung Tizen, LG WebOS, HbbTV)

<Info>
  The CTV addon must be enabled for your project before use. Enable it in the Permutive dashboard under **Settings > Integrations**.
</Info>

## Initializing the CTV Addon

Initialize the addon when a user starts watching a video. Calling `permutive.addon("ctv", ...)` automatically tracks a `Videoview` event and prepares the addon for engagement tracking.

```javascript theme={"dark"}
permutive.addon("ctv", {
  duration: 3600000, // Content duration in milliseconds
  videoProperties: {
    title: "Breaking Bad - S1E1",
    genre: ["Drama", "Thriller"],
    content_type: ["Series", "Episode"],
    age_rating: "TV-MA",
    runtime: 3600,
    season_number: 1,
    episode_number: 1,
    audio_language: "en",
    iab_categories: ["IAB1-1"]
  }
})
```

After initialization, the addon is available at `permutive.addons.ctv`.

### Duration

Pass `duration` in **milliseconds**. This is required for the `VideoCompletion` event to include an accurate `completion` percentage. If the duration is not known at initialization time, you can set it later using `.setDuration()` before calling `.stop()`.

```javascript theme={"dark"}
// Set duration later when it becomes available
permutive.addons.ctv.setDuration(videoDurationMs)
```

## Player Lifecycle

Map your video player's events to the CTV addon methods. The addon handles engagement timing internally — there is no need to implement your own interval or timer logic.

| Addon Method        | When to Call                         | Effect                                              |
| :------------------ | :----------------------------------- | :-------------------------------------------------- |
| `.play(position?)`  | Player starts playing or user scrubs | Starts or resumes engagement timer                  |
| `.pause(position?)` | Player pauses                        | Pauses engagement timer                             |
| `.stop(position?)`  | User exits or content ends           | Tracks `VideoCompletion` with aggregated engagement |

Positions are passed in **milliseconds**.

### Generic HTML5 Video Example

```javascript theme={"dark"}
// Initialize the addon when the user starts a video
permutive.addon("ctv", {
  duration: videoElement.duration * 1000,
  videoProperties: {
    title: "My Video Title",
    genre: ["Documentary"]
  }
})

videoElement.addEventListener("play", function () {
  permutive.addons.ctv.play(videoElement.currentTime * 1000)
})

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

videoElement.addEventListener("ended", function () {
  permutive.addons.ctv.stop(videoElement.currentTime * 1000)
})
```

### Handling Duration Not Yet Available

If `duration` is not available when the video starts (common with live streams or when metadata loads asynchronously), initialize without it and call `.setDuration()` once the value is known:

```javascript theme={"dark"}
videoElement.addEventListener("loadedmetadata", function () {
  permutive.addons.ctv.setDuration(videoElement.duration * 1000)
})
```

### Handling Seek and Scrub

When a user seeks to a new position, call `.play()` with the new position. This correctly updates the engagement tracker's reference point without inflating engaged time:

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

### Handling Buffering

Pause engagement tracking while the player is buffering so that buffer time is not counted as active engagement:

```javascript theme={"dark"}
videoElement.addEventListener("waiting", function () {
  permutive.addons.ctv.pause()
})

videoElement.addEventListener("playing", function () {
  permutive.addons.ctv.play(videoElement.currentTime * 1000)
})
```

## Video Ad Tracking

Track video ad events using the addon's `.track()` method. For mid-roll ads, pause the main content tracker while the ad plays and resume it when the ad finishes.

### Ad Event Reference

| Event               | When to Track                    |
| :------------------ | :------------------------------- |
| `VideoAdView`       | When a video ad starts playing   |
| `VideoAdCompletion` | When a video ad finishes playing |
| `VideoAdClicked`    | When a user clicks on a video ad |

### Ad Event Properties

Properties for video ad events are nested under the `ad` key:

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

### Pre-roll Ads

```javascript theme={"dark"}
// Track the ad before initializing the main content
const onPrerollStart = function (ad) {
  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration,
      campaign_id: ad.campaignId,
      creative_id: ad.creativeId
    }
  })
}

const onPrerollEnd = function (ad) {
  permutive.addons.ctv.track("VideoAdCompletion", {
    ad: {
      title: ad.title,
      duration: ad.duration
    }
  })

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

### Mid-roll Ads

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

  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration,
      campaign_id: ad.campaignId
    }
  })
}

const onMidrollEnd = function (ad) {
  permutive.addons.ctv.track("VideoAdCompletion", {
    ad: {
      title: ad.title,
      duration: ad.duration
    }
  })

  // Resume main content engagement tracking
  permutive.addons.ctv.play(videoElement.currentTime * 1000)
}
```

### Post-roll Ads

```javascript theme={"dark"}
const onContentEnd = function () {
  // Complete main content tracking before post-roll
  permutive.addons.ctv.stop()
}

const onPostrollStart = function (ad) {
  permutive.addons.ctv.track("VideoAdView", {
    ad: {
      title: ad.title,
      duration: ad.duration
    }
  })
}
```

## Video Properties

Pass video metadata under the `videoProperties` key when initializing the addon. All properties are optional but richer metadata enables more precise cohort building.

| Property               | Type      | Description                               |
| :--------------------- | :-------- | :---------------------------------------- |
| `title`                | string    | Video title                               |
| `genre`                | string\[] | List of genres                            |
| `content_type`         | string\[] | List of content types                     |
| `age_rating`           | string    | Age rating (e.g. "PG-13", "TV-MA")        |
| `runtime`              | number    | Runtime in seconds                        |
| `country`              | string    | Origin country                            |
| `original_language`    | string    | Original language of the content          |
| `audio_language`       | string    | Language of the audio track being watched |
| `subtitles.enabled`    | boolean   | Whether subtitles were enabled            |
| `subtitles.language`   | string    | Subtitle language                         |
| `season_number`        | number    | Season number                             |
| `episode_number`       | number    | Episode number                            |
| `consecutive_episodes` | number    | Number of episodes watched consecutively  |
| `iab_categories`       | string\[] | IAB content taxonomy categories           |

Properties are type-checked by the addon. A property with the wrong type (for example, passing `audio_language` as an array instead of a string) will be silently removed from the event rather than causing a schema error.

## Complete Working Example

A full integration with an HTML5 video element, including ad break handling:

```javascript theme={"dark"}
var videoElement = document.getElementById("my-video")
var videoDurationMs = 0

// Step 1: Capture duration when metadata loads
videoElement.addEventListener("loadedmetadata", function () {
  videoDurationMs = videoElement.duration * 1000
})

// Step 2: Initialize the CTV addon when playback begins
//         This automatically tracks the Videoview event.
videoElement.addEventListener("play", function () {
  if (!permutive.addons.ctv) {
    permutive.addon("ctv", {
      duration: videoDurationMs,
      videoProperties: {
        title: videoElement.dataset.title,
        genre: ["Drama"],
        season_number: 1,
        episode_number: 1,
        audio_language: "en"
      }
    })
  }
  permutive.addons.ctv.play(videoElement.currentTime * 1000)
})

// Step 3: Pause engagement tracking when player pauses
videoElement.addEventListener("pause", function () {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.pause(videoElement.currentTime * 1000)
  }
})

// Step 4: Handle buffering
videoElement.addEventListener("waiting", function () {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.pause()
  }
})
videoElement.addEventListener("playing", function () {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.play(videoElement.currentTime * 1000)
  }
})

// Step 5: Handle seeking
videoElement.addEventListener("seeked", function () {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.play(videoElement.currentTime * 1000)
  }
})

// Step 6: Stop tracking when content ends (tracks VideoCompletion)
videoElement.addEventListener("ended", function () {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.stop(videoElement.currentTime * 1000)
  }
})

// Ad tracking helpers
function onAdStart(ad) {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.pause()
    permutive.addons.ctv.track("VideoAdView", {
      ad: {
        title: ad.title,
        duration: ad.duration,
        campaign_id: ad.campaignId
      }
    })
  }
}

function onAdEnd(ad) {
  if (permutive.addons.ctv) {
    permutive.addons.ctv.track("VideoAdCompletion", {
      ad: {
        title: ad.title,
        duration: ad.duration
      }
    })
    permutive.addons.ctv.play(videoElement.currentTime * 1000)
  }
}
```

## Engagement Tracking Patterns

The CTV addon tracks engagement automatically. You do not need to implement interval timers or milestone checks manually.

The addon's internal engagement model works as follows:

1. **Play** — When `.play()` is called, the engagement clock starts.
2. **Pause** — When `.pause()` is called, the clock stops. Time elapsed since the last `.play()` call is accumulated.
3. **Resume** — When `.play()` is called again, the clock resumes from the current accumulated total.
4. **Stop** — When `.stop()` is called, the final accumulated `engaged_time` and the calculated `completion` percentage are included in the `VideoCompletion` event automatically.

This means skipped content (via seeking) is never counted as engaged time, and buffering time can be excluded by calling `.pause()` during buffer states.

## Best Practices

<AccordionGroup>
  <Accordion title="Always Call stop() When the User Exits">
    The `VideoCompletion` event is only tracked when `.stop()` is called. Make sure you call it not just when a video ends naturally, but also when a user navigates away or closes the player early.

    ```javascript theme={"dark"}
    // On natural end
    videoElement.addEventListener("ended", function () {
      permutive.addons.ctv.stop(videoElement.currentTime * 1000)
    })

    // On user navigation / page unload
    window.addEventListener("beforeunload", function () {
      if (permutive.addons.ctv) {
        permutive.addons.ctv.stop()
      }
    })
    ```
  </Accordion>

  <Accordion title="Provide Duration Accurately">
    The `completion` percentage in `VideoCompletion` is calculated from the duration you provide. Set `duration` in milliseconds at initialization, or call `.setDuration()` once metadata is available. Do not guess or hardcode durations.

    ```javascript theme={"dark"}
    videoElement.addEventListener("loadedmetadata", function () {
      permutive.addons.ctv.setDuration(videoElement.duration * 1000)
    })
    ```
  </Accordion>

  <Accordion title="Initialize Once Per Content Session">
    Call `permutive.addon("ctv", ...)` once per piece of content. Calling it again reinitializes the addon and tracks a new `Videoview` event. If the user watches multiple videos in a session, reinitialize for each new piece of content after calling `.stop()` on the previous one.

    ```javascript theme={"dark"}
    // When moving to next video
    permutive.addons.ctv.stop()

    permutive.addon("ctv", {
      duration: nextVideoDurationMs,
      videoProperties: { title: "Next Video Title" }
    })
    ```
  </Accordion>

  <Accordion title="Pause During Ad Breaks">
    Call `.pause()` when a mid-roll ad starts so that ad playback time is not included in the main content's `engaged_time`. Resume with `.play()` when the ad finishes.
  </Accordion>

  <Accordion title="Include Rich Video Metadata">
    Every property you pass in `videoProperties` becomes available for cohort building and targeting. At minimum, include `title`. Adding `genre`, `content_type`, `season_number`, and `episode_number` significantly increases the granularity of video-based cohorts.
  </Accordion>
</AccordionGroup>

## Troubleshooting

<AccordionGroup>
  <Accordion title="Videoview event is not appearing in the dashboard">
    **Problem:** The `Videoview` event is not visible in Permutive analytics.

    **Solutions:**

    * Confirm the CTV addon is enabled under **Settings > Integrations** in the Permutive dashboard.
    * Verify `permutive.addon("ctv", ...)` is being called in the browser console — it should not throw an error.
    * Check that the SDK is initialized before `permutive.addon("ctv", ...)` is called.
    * Allow 5-10 minutes for events to appear in the dashboard after they fire.
  </Accordion>

  <Accordion title="permutive.addons.ctv is undefined">
    **Problem:** Calling `permutive.addons.ctv.play()` throws because the addon is not available.

    **Solutions:**

    * Ensure `permutive.addon("ctv", ...)` has been called before accessing `permutive.addons.ctv`.
    * Add a guard check: `if (permutive.addons.ctv) { ... }` before calling addon methods.
    * Confirm the CTV addon is enabled in your workspace settings — it will not be registered if it has not been enabled.
  </Accordion>

  <Accordion title="VideoCompletion shows 0% completion">
    **Problem:** The `VideoCompletion` event has a `completion` value of `0` or is missing it.

    **Solutions:**

    * Ensure `duration` is passed (in milliseconds) when calling `permutive.addon("ctv", ...)`.
    * If duration is not available at initialization, call `.setDuration(ms)` before `.stop()` is called.
    * Verify that the duration value is in milliseconds, not seconds.
  </Accordion>

  <Accordion title="Engaged time is higher than expected">
    **Problem:** The `engaged_time` in `VideoCompletion` includes buffering or paused time.

    **Solutions:**

    * Call `.pause()` when the player enters a buffering state (`waiting` event on HTML5 video).
    * Call `.pause()` when the player is paused by the user.
    * Verify that `.pause()` is being called before ad breaks start.
  </Accordion>

  <Accordion title="Duplicate Videoview events">
    **Problem:** Multiple `Videoview` events fire for a single piece of content.

    **Solutions:**

    * `permutive.addon("ctv", ...)` fires a `Videoview` each time it is called. Guard the initialization so it only runs once per content session.
    * Use a flag or check `permutive.addons.ctv` to avoid re-initializing on repeated `play` events from the player.
  </Accordion>
</AccordionGroup>

## Related Documentation

<CardGroup cols={2}>
  <Card title="CTV Video Tracking" icon="tv" href="/sdks/ctv/video-tracking">
    Video event schema and cross-platform tracking patterns
  </Card>

  <Card title="Video Integrations" icon="plug" href="/integrations/video/catalog">
    Pre-built integrations for JW Player, Brightcove, YouTube, and more
  </Card>

  <Card title="Event Tracking" icon="bolt" href="/sdks/web/javascript-sdk/features/event-tracking">
    General event tracking with the JavaScript SDK
  </Card>

  <Card title="Web CTV" icon="browser" href="/sdks/ctv/web-ctv">
    Full CTV addon reference for web-based CTV platforms
  </Card>
</CardGroup>
