メインコンテンツまでスキップ

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:

  1. Server-side Token Service — Securely calls the BlendVision CMS API to issue playback tokens
  2. Content Metadata Resolution — Uses the /contents API to obtain the correct identifiers for analytics
  3. Live Event Lifecycle Management — Handles waiting room, live playback, stream end, and recording transition
  4. Player Initialization — Configures DRM and analytics parameters per browser
  5. 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: "..." }
}
resource_type Values for Token Request

Use the correct resource_type enum when requesting tokens:

Content TypeToken Request resource_type
Live EventRESOURCE_TYPE_LIVE_EVENT
VOD EventRESOURCE_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:

  1. The response id is the correct resource_id for analytics (different from the Live ID used in the token request)
  2. The response type determines the analytics resource_type and 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
}
Use the id from /contents, NOT your original resource ID

The 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 SourceExampleUse For
BV One URL / your configdf45e247-5e49-4f2e-a2f4-4c9a0400f9cf (Live ID)Token request resource_id
/contents API response id0fe6bbee-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 typeanalytics.resource_type
LIVE_EVENTRESOURCE_TYPE_LIVE_EVENT
LIVE_EVENT_RECORDINGRESOURCE_TYPE_LIVE_EVENT_RECORDING
VOD_EVENTRESOURCE_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 / PlatformProtocolDRM System
Safari, iOS, ElectronHLSFairPlay
EdgeDASHWidevine
Chrome, Firefox, othersDASHWidevine + 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);
Must Re-initialize Player on Live-to-VOD Transition

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

ItemCorrectCommon Mistake
Token resource_typeRESOURCE_TYPE_LIVE_EVENT / RESOURCE_TYPE_VOD_EVENTRESOURCE_TYPE_LIVE / RESOURCE_TYPE_VOD
Analytics resource_idid from GET /contents responseLive ID from URL or config
Analytics resource_typeRESOURCE_TYPE_LIVE_EVENT / RESOURCE_TYPE_LIVE_EVENT_RECORDING / RESOURCE_TYPE_VOD_EVENTHardcoded single value for all states
Live-to-VOD transitionRe-fetch token + destroy + re-create playerKeep playing with stale token/type
Waiting room (403 / not playable)Show cover image + poll for startShow error or blank screen
DRM source selectionSafari/iOS → HLS+FairPlay, others → DASH+WidevineSame source for all browsers
User identificationanalytics.user_id = your custom viewer IDRandom UUID or missing
Token cachingCache token, re-fetch on auth errorRe-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);
}