Track Wistia Videos
Wistia’s embed API is built around a queue — window._wq — that you can push to before the Wistia script even loads. That makes wiring up tracking particularly clean: one push, any number of videos, lifecycle events delivered to a single callback.
Valid as of April 2026, Wistia Player API.
Problem
Section titled “Problem”Wistia’s built-in analytics is excellent inside Wistia, but the events don’t appear in GA4 by default. A dataLayer bridge unifies Wistia plays with YouTube, Vimeo, and HTML5 under the same video_engagement event family, so reports and audiences work across providers.
Solution
Section titled “Solution”Push a single matcher object into window._wq with an onReady callback. Wistia calls the callback once per video embed, passing you the video instance. Bind play, pause, end, and percentwatchedchanged — dedupe to 25/50/75/90/100 milestones — and push dataLayer events.
The _wq queue
Section titled “The _wq queue”One Custom HTML tag handles every Wistia video on the page. The _wq queue accepts the push before or after Wistia's script loads.
(function() { window._wq = window._wq || [];
window._wq.push({ id: '_all', // Match every Wistia video on the page onReady: function(video) { var meta = { hashedId: video.hashedId(), title: null, duration: 0 }; var milestones = [25, 50, 75, 90, 100]; var fired = {};
// Name and duration are only reliable once metadata is ready try { meta.title = video.name(); meta.duration = Math.round(video.duration()); } catch (e) { // Some assets expose name/duration lazily — retry in the first event }
function push(action, percent) { if (!meta.title) { try { meta.title = video.name(); } catch (e) {} try { meta.duration = Math.round(video.duration()); } catch (e) {} } window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'video_engagement', video_provider: 'wistia', video_action: action, video_title: meta.title, video_url: 'https://fast.wistia.net/embed/iframe/' + meta.hashedId, video_hashed_id: meta.hashedId, video_duration: meta.duration, video_percent: percent != null ? percent : 0 }); }
video.bind('play', function() { if (!fired.start) { fired.start = true; push('start', 0); } else { push('resume', Math.round(video.percentWatched() * 100)); } });
video.bind('pause', function() { push('pause', Math.round(video.percentWatched() * 100)); });
video.bind('end', function() { push('complete', 100); });
// Wistia fires this very often — dedupe to our milestone set video.bind('percentwatchedchanged', function(percent) { var pct = Math.round(percent * 100); milestones.forEach(function(m) { if (pct >= m && !fired[m]) { fired[m] = true; push('progress_' + m, m); } }); }); } });})();GTM setup
Section titled “GTM setup”-
Add the script as a Custom HTML tag
- Trigger: All Pages or DOM Ready
- The
_wqqueue pattern works even if Wistia hasn’t loaded yet, so timing is forgiving
-
Create a Custom Event trigger
- Trigger type: Custom Event
- Event name:
video_engagement
-
Create Data Layer Variables
DLV - video_provider→video_providerDLV - video_action→video_actionDLV - video_title→video_titleDLV - video_url→video_urlDLV - video_hashed_id→video_hashed_idDLV - video_percent→video_percentDLV - video_duration→video_duration
-
Create a GA4 Event Tag
- Event name:
video_engagement - Parameters: all DLVs above, plus
page_path→{{Page Path}} - Trigger: the Custom Event trigger
- Event name:
GA4 - video_engagement (Wistia)
- Type
- Google Analytics: GA4 Event
- Trigger
- Custom Event - video_engagement
- Variables
-
DLV - video_providerDLV - video_actionDLV - video_titleDLV - video_hashed_idDLV - video_percent
Test it
Section titled “Test it”- Open GTM Preview on a page with a Wistia embed
- Press Play — confirm
video_engagementwithvideo_action: start, populatedvideo_title, and a validvideo_hashed_id - Pause at around 35% — confirm
video_action: pausewithvideo_percentnear 35 - Let playback cross each milestone — confirm
progress_25,progress_50,progress_75,progress_90,progress_100fire once each - In GA4 DebugView, verify
video_provider: wistiaon every event and thatvideo_hashed_idmatches the Wistia media ID
Common gotchas
Section titled “Common gotchas”_all matcher catches Channel embeds too. Wistia Channels, series players, and popover embeds all bind through _all. That’s usually what you want, but if you’re counting “unique videos played” you’ll double-count a video that also appears in a channel tile.
percentwatchedchanged fires very frequently. It emits on every sub-second change. The milestone dedupe in the code above is what keeps event volume sane. Never push a raw event from this binding without deduping — you’ll burn through GA4 event quota.
Seeking triggers milestones out of order. If a viewer scrubs forward to 80%, progress_25, progress_50, progress_75 all fire near-simultaneously because percentWatched() crossed each threshold at once. That’s correct behaviour for “watched at least X%” metrics, but will inflate time-to-milestone calculations. If that matters, track video.secondsWatched() separately.
Metadata lazy availability. On some Wistia plans and asset types, video.name() and video.duration() return empty on the first onReady tick. The retry inside push() handles that — keep it.
Multiple _wq pushes are additive. If another script already pushed an id: '_all' handler, yours runs alongside it, not instead of it. That’s intentional. If you see duplicate events, search your codebase for other _wq.push calls.
Autoplay muted counts as start. Same caveat as YouTube and Vimeo — set a user-gesture flag if you need to distinguish intentional plays.
Popover embeds fire onReady only when opened. For popover / lightbox-triggered Wistia videos, onReady doesn’t fire until the viewer actually opens the popover. That’s fine for tracking — just don’t expect pre-play metadata in that flow.