Skip to content

Payload & API

Every fired event sends the same shape. Field order is deterministic: { event, url, time, duration, page, ...metadata }, then session, then title.

Field Type Description
event string 'play', 'listen', or 'complete'.
url string The player’s options.url (typically from data-url). Required — events without it are dropped with a warning.
time number For play / listen: floor(accumulated media-time). For complete: floor(currentTime).
duration number floor(duration) of the track, in seconds. 0 when unknown — a live stream (duration Infinity) or before metadata has loaded (1.1.0+; previously null). complete never fires for a live stream.
page string window.location.pathname — path only, no query string or host.
…metadata any Each key from your metadata config.
session string Present only when session is enabled.
title string Present only when the player has options.title (typically from data-title).
{
"event": "listen",
"url": "audio/ep-42.mp3",
"time": 30,
"duration": 180,
"page": "/podcast/ep-42",
"post_id": 456,
"session": "k3f9q2abc1",
"title": "Episode 42"
}
Event Terminal? Transport
play No Plain fetch POST, keepalive: false.
listen Yes fetch + keepalive. A same-origin endpoint with no custom headers uses navigator.sendBeacon (Blob, application/json) instead, falling back to fetch + keepalive if the beacon is refused.
complete Yes Same as listen.

Delivery priority is absolute: if handler is set, it wins outright and endpoint is never called. Otherwise the payload is POSTed to endpoint as JSON (Content-Type: application/json). Terminal events (listen, complete) must survive page unload, so they’re sent in a way that does:

  • Same-origin endpoint, no custom headers → sendBeacon, which sends the page’s cookies.
  • Cross-origin endpoint, or any custom headers → fetch with keepalive: true (1.1.0+ for cross-origin; earlier versions used a beacon, which a wildcard-CORS endpoint never received even though sendBeacon reported success). fetch sends cookies only same-origin, so a cross-origin endpoint answering Access-Control-Allow-Origin: * works.
  • sendBeacon is also the fallback when fetch isn’t available.

fetch failures — including a CORS rejection — are logged via console.error.

A full setup — players on the page, a tracker POSTing to your API, and a minimal server route to receive it.

<!-- 1. Players (auto-mounted by the player's data-* contract) -->
<div data-waveform-player
data-url="audio/ep-42.mp3"
data-title="Episode 42"></div>
<script src="/js/waveform-player.js"></script>
<script src="/js/waveform-tracker.js"></script>
// 2. Initialize the tracker after the player script has loaded.
WaveformTracker.init({
endpoint: 'https://api.example.com/track',
events: { play: 3, listen: 30, complete: 90 },
headers: { 'Authorization': 'Bearer ' + window.API_TOKEN }, // forces fetch path
metadata: { post_id: 456, user_id: window.currentUserId },
session: true,
debug: false
});
// 3. Example endpoint (Express). The body is the payload object above.
// Cross-origin: answer the CORS preflight for the JSON body + custom headers.
app.use('/track', (req, res, next) => {
res.set('Access-Control-Allow-Origin', 'https://www.example.com');
res.set('Access-Control-Allow-Headers', 'content-type, authorization');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.post('/track', express.json(), async (req, res) => {
const { event, url, time, duration, page, session, title, post_id } = req.body;
// event: 'play' | 'listen' | 'complete'
// time: media-seconds heard (play/listen) or currentTime (complete)
await db.listens.insert({
event, url, title, seconds: time, duration,
page, session, post_id,
at: new Date()
});
res.sendStatus(204); // beacons ignore the response; 2xx keeps logs clean
});

When you’d rather route events through your own analytics layer (Segment, GA, a queue) instead of HTTP, pass a handler. It receives every payload and fully replaces network delivery — endpoint is ignored.

WaveformTracker.init({
events: { listen: 30, complete: 90 },
handler(payload) {
// payload === { event, url, time, duration, page, ...metadata, session?, title? }
window.analytics?.track('Audio ' + payload.event, payload);
}
});

init() is the only entry point you normally need; the rest are for inspection, SPA control, and teardown.

Method Returns Description
init(config = {}) undefined Configure the singleton, attach document ready/destroy listeners (once), and track existing players. Calling it again reconfigures in place — tracked players pick up the new config and the session id is kept (1.1.0+).
trackPlayer(player) undefined Begin tracking one player. Validates player.container + player.options (warns and bails if invalid); idempotent. Before init() or after reset() it warns and does nothing (1.1.0+).
untrackPlayer(player) undefined Run the threshold checks one last time at the last known position (1.1.0+), then stop tracking the player and remove its container listeners. No-op if not tracked.
trackAllPlayers() undefined Track every instance from WaveformPlayer.getAllInstances(). Called automatically by init().
getStats() Array One entry per tracked player: { url, title, elapsedTime, isTracking, sentEvents }.
getTrackedCount() number How many players are currently tracked.
reset() undefined Untrack all players, remove the document ready/destroy listeners (1.1.0+), clear the trackers Map, and null config + sessionId. Players created afterwards are ignored until the next init().
WaveformTracker.getTrackedCount();
// → 2
WaveformTracker.getStats();
// → [
// { url: 'audio/ep-42.mp3', title: 'Episode 42',
// elapsedTime: 47.3, isTracking: true, sentEvents: ['play', 'listen'] }
// ]

elapsedTime is accumulated media-seconds (a float); sentEvents lists the event names already fired this playback.

WaveformTracker is purely reactive — it listens to player events and never renders anything. For the player’s full event surface, see Player events.

Source Event Effect
document waveformplayer:ready Captured (capture phase) → trackPlayer(detail.player).
document waveformplayer:destroy Captured → untrackPlayer(detail.player), which runs a final unthrottled threshold check first (1.1.0+). Prevents SPA leaks (player v1.8.0+).
container waveformplayer:play If the player’s URL changed since the last event, reset sentEvents / elapsedTime (1.0.2+). Start tracking; reset the media-time baseline (lastTime = null).
container waveformplayer:pause Run every threshold check unthrottled at the last known position (1.1.0+), then stop tracking and clear the baseline. Accumulated elapsedTime is kept.
container waveformplayer:timeupdate { currentTime, duration } → reset on a URL change (as for play), then accumulate deltas and run threshold checks.
container waveformplayer:ended { currentTime, duration } → credit the stretch since the last timeupdate (same background-tab rule as above), run every threshold check unthrottled at that position — complete included (1.1.0+) — then reset sentEvents / elapsedTime for replay. Reads time from the detail, so it works in the player’s external audio mode.

Every tracker message is prefixed [WaveformTracker].

  • warn and error are always emitted, even with debug: false, so integration mistakes (no endpoint/handler, missing url, a thrown handler, dropped events) surface immediately.
  • Verbose log tracing (player ready/destroy, play/pause, sent payloads, beacon fallbacks) is gated behind debug: true.

The init() validation warning — No endpoint or handler configured; events will not be delivered — fires regardless of debug.