Live Event Analytics Integration
This guide covers the complete integration flow for embedding a BlendVision live event player into a custom landing page, with correct analytics tracking and full lifecycle management — from the waiting room, through live playback, to Live-to-VOD (recording) transition.
For basic player integration, see Getting Started. For analytics module basics, see Analytics Module.
Overview
When building a custom landing page that hosts a BlendVision player for live events, the integration involves:
- Server-side Token Service — Securely calls the BlendVision CMS API to issue playback tokens
- Content Metadata Resolution — Uses the
/contentsAPI to obtain the correct identifiers for analytics - Live Event Lifecycle Management — Handles waiting room, live playback, stream end, and recording transition
- Player Initialization — Configures DRM and analytics parameters per browser
- Polling — Detects live start, live end, and content type transitions
Live Event Lifecycle
A live event goes through multiple states. Your integration must handle each transition:
Sequence Diagram
Step 1: Server-Side Token Service
Your backend service calls the BlendVision CMS API to issue playback tokens. This keeps your CMS API token (BLENDVISION_API_TOKEN) secure and never exposed to the client.
POST /bv/cms/v1/tokens
// Server-side token endpoint (Node.js example)
const RESOURCE_TYPE_MAP = {
vod: 'RESOURCE_TYPE_VOD_EVENT',
live: 'RESOURCE_TYPE_LIVE_EVENT',
};
async function requestPlaybackToken(resourceId, customerId, resourceType = 'vod') {
const response = await fetch('https://api.one.blendvision.com/bv/cms/v1/tokens', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BLENDVISION_API_TOKEN}`,
'Content-Type': 'application/json',
'x-bv-org-id': process.env.BLENDVISION_ORG_ID,
},
body: JSON.stringify({
resource_id: resourceId,
resource_type: RESOURCE_TYPE_MAP[resourceType],
customer_id: customerId,
}),
});
return response.json(); // { token: "..." }
}
Use the correct resource_type enum when requesting tokens:
| Content Type | Token Request resource_type |
|---|---|
| Live Event | RESOURCE_TYPE_LIVE_EVENT |
| VOD Event | RESOURCE_TYPE_VOD_EVENT |
Do not use RESOURCE_TYPE_VOD or RESOURCE_TYPE_LIVE — these refer to legacy Live/VOD resources (not Live Events/VOD Events created through BlendVision One).
Step 2: Retrieve Content Metadata
After obtaining the playback token, call the /contents API to get the content metadata. This step is critical for two reasons:
- The response
idis the correctresource_idfor analytics (different from the Live ID used in the token request) - The response
typedetermines the analyticsresource_typeand current playback state
GET /bv/playback/v1/contents
async function fetchContentMetadata(playbackToken) {
const response = await fetch(
'https://api.one.blendvision.com/bv/playback/v1/contents',
{
headers: {
Authorization: `Bearer ${playbackToken}`,
Accept: 'application/json',
},
}
);
// Handle "not yet playable" (before scheduled start, between live and recording, etc.)
if (response.status === 403) {
const body = await response.json().catch(() => null);
if (isNotYetPlayable(body)) {
return {
playable: false,
coverImageUrl: body?.cover_image_url ?? body?.player_setting?.cover_image_url,
};
}
throw new Error(`Contents API forbidden: ${response.status}`);
}
if (!response.ok) {
throw new Error(`Contents API error: ${response.status}`);
}
const data = await response.json();
return {
id: data.id, // analytics.resource_id
type: data.type, // e.g., "LIVE_EVENT", "LIVE_EVENT_RECORDING", "VOD_EVENT"
playable: data.playable,
coverImageUrl: data.cover_image_url,
title: data.title,
isEnded: data.is_ended,
};
}
/**
* Detect 403 responses that indicate "not yet playable"
* (before scheduled start or between live end and recording availability)
*/
function isNotYetPlayable(body) {
if (!body) return false;
if (body.code === 7) return true;
const msg = (body.message ?? '').toLowerCase();
return msg.includes('status or scheduled time invalid');
}
Example success response:
{
"id": "0fe6bbee-f2bc-44c2-ba6d-b43430fd28bb",
"playable": true,
"type": "LIVE_EVENT",
"title": "My Live Stream",
"cover_image_url": "https://...",
"is_ended": false
}
id from /contents, NOT your original resource IDThe resource_id you pass to the token API (e.g., the Live ID from the BlendVision One URL) is not the same as the analytics resource ID. The /contents API returns the actual Live Event ID that maps to analytics reports. If you use the wrong ID, reports will not show the content title and data will be mismatched.
| ID Source | Example | Use For |
|---|---|---|
| BV One URL / your config | df45e247-5e49-4f2e-a2f4-4c9a0400f9cf (Live ID) | Token request resource_id |
/contents API response id | 0fe6bbee-f2bc-44c2-ba6d-b43430fd28bb (Live Event ID) | analytics.resource_id |
Step 3: Determine the Analytics Resource Type
Map the type from the /contents response to the correct analytics.resource_type:
function getAnalyticsResourceType(contentType) {
const normalized = (contentType || '').trim().toLowerCase();
if (normalized.includes('recording')) {
return 'RESOURCE_TYPE_LIVE_EVENT_RECORDING';
}
if (normalized.includes('live')) {
return 'RESOURCE_TYPE_LIVE_EVENT';
}
return 'RESOURCE_TYPE_VOD_EVENT';
}
/contents response type | analytics.resource_type |
|---|---|
LIVE_EVENT | RESOURCE_TYPE_LIVE_EVENT |
LIVE_EVENT_RECORDING | RESOURCE_TYPE_LIVE_EVENT_RECORDING |
VOD_EVENT | RESOURCE_TYPE_VOD_EVENT |
Using incorrect resource_type values (e.g., RESOURCE_TYPE_VOD instead of RESOURCE_TYPE_VOD_EVENT) will cause analytics reports to fail to correlate the data with the correct content title.
Step 4: Start a Session and Initialize the Player
Start the playback session, retrieve stream URLs, and initialize the player with the correct DRM and analytics configuration.
4a. Start Session and Get Stream Info
const deviceId = crypto.randomUUID();
// Start session (may also return 403 if not yet playable)
const sessionResponse = await fetch(
`https://api.one.blendvision.com/bv/playback/v1/sessions/${deviceId}:start`,
{ method: 'POST', headers: { Authorization: `Bearer ${playbackToken}` } }
);
if (sessionResponse.status === 403) {
const body = await sessionResponse.json().catch(() => null);
if (isNotYetPlayable(body)) {
// Show waiting room — same as /contents 403 handling
return;
}
}
// Get stream info
const streamInfo = await fetch(
`https://api.one.blendvision.com/bv/playback/v1/sessions/${deviceId}`,
{ headers: { Authorization: `Bearer ${playbackToken}` } }
).then(res => res.json());
const { manifests } = streamInfo.sources[0];
const dashUrl = manifests.find(m => m.protocol === 'PROTOCOL_DASH')?.url;
const hlsUrl = manifests.find(m => m.protocol === 'PROTOCOL_HLS')?.url;
4b. Select DRM Configuration by Browser
Different browsers require different streaming protocols and DRM systems. Select the appropriate source based on the viewer's browser:
| Browser / Platform | Protocol | DRM System |
|---|---|---|
| Safari, iOS, Electron | HLS | FairPlay |
| Edge | DASH | Widevine |
| Chrome, Firefox, others | DASH | Widevine + PlayReady |
const DRM_LICENSE_URL = 'https://drm.platform.blendvision.com/api/v3/drm/license';
const FAIRPLAY_CERT_URL = `${DRM_LICENSE_URL}/fairplay_cert`;
const drmHeaders = {
'x-custom-data': `token_type=upfront&token_value=${playbackToken}`,
};
function buildSource(manifests, playbackToken) {
const dashUrl = manifests.find(m => m.protocol === 'PROTOCOL_DASH')?.url;
const hlsUrl = manifests.find(m => m.protocol === 'PROTOCOL_HLS')?.url;
const isFairPlayBrowser = isSafari() || isIOS() || isElectron();
if (isFairPlayBrowser && hlsUrl) {
return [{
type: 'application/x-mpegurl',
src: hlsUrl,
drm: {
fairplay: {
licenseUri: DRM_LICENSE_URL,
certificateUri: FAIRPLAY_CERT_URL,
headers: drmHeaders,
certificateHeaders: drmHeaders,
},
},
}];
}
if (dashUrl) {
const drmConfig = {
widevine: { licenseUri: DRM_LICENSE_URL, headers: drmHeaders },
};
// Edge has issues with PlayReady — use Widevine only
if (!isEdge()) {
drmConfig.playready = { licenseUri: DRM_LICENSE_URL, headers: drmHeaders };
}
return [{
type: 'application/dash+xml',
src: dashUrl,
drm: drmConfig,
}];
}
// Fallback to HLS if no DASH available
if (hlsUrl) {
return [{
type: 'application/x-mpegurl',
src: hlsUrl,
drm: {
fairplay: {
licenseUri: DRM_LICENSE_URL,
certificateUri: FAIRPLAY_CERT_URL,
headers: drmHeaders,
certificateHeaders: drmHeaders,
},
},
}];
}
throw new Error('No stream URL found');
}
See the DRM guide for more details on DRM configuration.
4c. Initialize the Player with Analytics
import { createPlayer } from '@blendvision/player';
const analyticsResourceId = contents.id; // from /contents API — NOT the Live ID
const analyticsResourceType = getAnalyticsResourceType(contents.type);
const player = createPlayer('player-container', {
licenseKey: 'YOUR_LICENSE_KEY',
modulesConfig: {
'analytics.user_id': customerId,
'analytics.resource_id': analyticsResourceId,
'analytics.resource_type': analyticsResourceType,
},
source: buildSource(manifests, playbackToken),
autoplay: true,
});
Step 5: Handle Live Event Lifecycle with Polling
A live event requires two polling loops to handle state transitions. Both should run at a reasonable interval (e.g., every 20 seconds).
5a. Waiting Room — Poll for Live Start
When /contents returns playable: false or a 403, show a cover image and poll until the live stream starts:
const POLL_INTERVAL_MS = 20_000;
let cachedToken = playbackToken;
// Show cover image while waiting
function showWaitingRoom(coverImageUrl) {
// Display cover_image_url as a preview placeholder
}
// Poll until content becomes playable
const waitingPollId = setInterval(async () => {
const contents = await fetchContentMetadata(cachedToken);
if (contents.playable) {
clearInterval(waitingPollId);
// Proceed to Step 4: start session and initialize player
await initializePlayer(contents, cachedToken);
}
}, POLL_INTERVAL_MS);
5b. During Playback — Poll for End and Live-to-VOD Transition
While the player is active, poll to detect when the live stream ends or transitions to a recording:
let currentContentType = contents.type; // e.g., "LIVE_EVENT"
const playbackPollId = setInterval(async () => {
try {
const updated = await fetchContentMetadata(cachedToken);
if (!updated.playable) {
// Live ended, recording not yet available — show ended screen
clearInterval(playbackPollId);
player.destroy();
showWaitingRoom(updated.coverImageUrl);
// Start polling again for recording availability
startWaitingPoll();
return;
}
// Detect type transition: LIVE_EVENT → LIVE_EVENT_RECORDING
if (updated.type !== currentContentType && updated.type?.includes('RECORDING')) {
currentContentType = updated.type;
// MUST destroy and re-create the player
player.destroy();
// Re-fetch token (old token may be revoked after live ends)
const { token: newToken } = await requestPlaybackToken(resourceId, customerId, 'live');
cachedToken = newToken;
// Re-initialize with updated analytics resource_type
await initializePlayer(updated, newToken);
}
} catch {
// Ignore polling errors — retry on next interval
}
}, POLL_INTERVAL_MS);
If the viewer stays on the same page/tab throughout the Live → Live-to-VOD transition without re-initializing the player, analytics events will continue using the old RESOURCE_TYPE_LIVE_EVENT and the old session. This causes:
- Live-to-VOD watch time not appearing in reports
- Incorrect resource type in analytics data
Always destroy and re-create the player instance when the content type changes.
5c. Token Caching and Retry
To avoid unnecessary API calls during polling, cache the playback token and only re-fetch when needed:
let cachedToken = null;
async function getPlaybackToken(forceRefresh = false) {
if (cachedToken && !forceRefresh) {
return cachedToken;
}
const { token } = await requestPlaybackToken(resourceId, customerId, 'live');
cachedToken = token;
return token;
}
async function fetchContentMetadataWithRetry(token) {
const result = await fetchContentMetadata(token);
// If cached token expired (401/403 auth error), retry with a fresh token
if (result.error && isAuthError(result.error)) {
const freshToken = await getPlaybackToken(true);
return fetchContentMetadata(freshToken);
}
return result;
}
function isAuthError(error) {
const msg = String(error);
return msg.includes('401') || msg.includes('403');
}
Complete Integration Checklist
| Item | Correct | Common Mistake |
|---|---|---|
Token resource_type | RESOURCE_TYPE_LIVE_EVENT / RESOURCE_TYPE_VOD_EVENT | RESOURCE_TYPE_LIVE / RESOURCE_TYPE_VOD |
Analytics resource_id | id from GET /contents response | Live ID from URL or config |
Analytics resource_type | RESOURCE_TYPE_LIVE_EVENT / RESOURCE_TYPE_LIVE_EVENT_RECORDING / RESOURCE_TYPE_VOD_EVENT | Hardcoded single value for all states |
| Live-to-VOD transition | Re-fetch token + destroy + re-create player | Keep playing with stale token/type |
| Waiting room (403 / not playable) | Show cover image + poll for start | Show error or blank screen |
| DRM source selection | Safari/iOS → HLS+FairPlay, others → DASH+Widevine | Same source for all browsers |
| User identification | analytics.user_id = your custom viewer ID | Random UUID or missing |
| Token caching | Cache token, re-fetch on auth error | Re-request token on every poll |
Reference: Full Playback Flow
// === Configuration ===
const POLL_INTERVAL_MS = 20_000;
let player = null;
let cachedToken = null;
let currentContentType = null;
let pollId = null;
// === Main Entry Point ===
async function start(resourceId, resourceType, customerId) {
// 1. Get playback token from your server
cachedToken = await getPlaybackToken(resourceId, resourceType, customerId);
// 2. Get content metadata
const contents = await fetchContentMetadata(cachedToken);
if (!contents.playable) {
showWaitingRoom(contents.coverImageUrl);
startWaitingPoll(resourceId, resourceType, customerId);
return;
}
// 3. Content is playable — initialize player
await initializePlayer(contents, cachedToken, customerId);
startPlaybackPoll(resourceId, resourceType, customerId);
}
// === Player Initialization ===
async function initializePlayer(contents, token, customerId) {
const deviceId = crypto.randomUUID();
// Start session
const sessionResp = await fetch(
`https://api.one.blendvision.com/bv/playback/v1/sessions/${deviceId}:start`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` } }
);
if (sessionResp.status === 403) {
showWaitingRoom(contents.coverImageUrl);
return;
}
// Get stream URLs
const streamInfo = await fetch(
`https://api.one.blendvision.com/bv/playback/v1/sessions/${deviceId}`,
{ headers: { Authorization: `Bearer ${token}` } }
).then(res => res.json());
const { manifests } = streamInfo.sources[0];
// Initialize player with correct analytics
currentContentType = contents.type;
player = createPlayer('player-container', {
licenseKey: 'YOUR_LICENSE_KEY',
modulesConfig: {
'analytics.user_id': customerId,
'analytics.resource_id': contents.id,
'analytics.resource_type': getAnalyticsResourceType(contents.type),
},
source: buildSource(manifests, token),
autoplay: true,
});
}
// === Polling: Waiting for Live Start ===
function startWaitingPoll(resourceId, resourceType, customerId) {
pollId = setInterval(async () => {
const contents = await fetchContentMetadata(cachedToken).catch(() => null);
if (contents?.playable) {
clearInterval(pollId);
await initializePlayer(contents, cachedToken, customerId);
startPlaybackPoll(resourceId, resourceType, customerId);
}
}, POLL_INTERVAL_MS);
}
// === Polling: During Playback (detect end / transition) ===
function startPlaybackPoll(resourceId, resourceType, customerId) {
pollId = setInterval(async () => {
try {
const updated = await fetchContentMetadata(cachedToken);
// Live ended — show waiting screen, poll for recording
if (!updated.playable) {
clearInterval(pollId);
player?.destroy();
player = null;
showWaitingRoom(updated.coverImageUrl);
startWaitingPoll(resourceId, resourceType, customerId);
return;
}
// Type changed to RECORDING — re-init player
if (updated.type !== currentContentType && updated.type?.includes('RECORDING')) {
player?.destroy();
player = null;
// Re-fetch token (old one may be revoked)
cachedToken = await getPlaybackToken(resourceId, resourceType, customerId);
await initializePlayer(updated, cachedToken, customerId);
}
} catch {
// Ignore — retry on next interval
}
}, POLL_INTERVAL_MS);
}