Install the SDK
then read the loop.
The @streamwake/player-sdk package attaches to a <video> element, batches the eight event types defined in the zod contract, and POSTs them to /api/v1/telemetry with a bearer API key. No cookie required — the SDK runs on third-party origins where the dashboard session cookie cannot reach.
Drop it into any web project.
The package is published as @streamwake/player-sdk. Install it alongside hls.js — hls.js is an optional peer dependency, but the init example wires it for the adaptive-bitrate signal you want to capture.
The package is MIT-licensed and ships TypeScript types straight from the wire format — the types in packages/player-sdk/src/types.ts are the same TelemetryEvent discriminated union the server validates against.
npm install @streamwake/player-sdk hls.jsThe dist/index.js bundle resolves to the closed set of eight EventType values described below. Pin a version for reproducibility; the@latest tag is fine in dev but not in production.
<script src="https://cdn.jsdelivr.net/npm/@streamwake/player-sdk@0.1.0/dist/index.js" type="module"></script>The package is open-source — bug reports, PRs, and feature requests live on the public repo. The wire-format contract is locked by a parity test against the server-side mirror, so every change reaches two CI gates in lockstep.
Open github.com/streamwake/player-sdkWire a TelemetryHandle.
Pass a TelemetryConfig to createTelemetry() with your bearer API key, the ingest endpoint, and per-session metadata. The factory returns a read-only TelemetryHandle that you attach to the <video> element.
The handle exposes four members — sessionId, emit, attach, end. There is no on() hook — the player fires events internally off the <video> element and (optionally) the hls.js instance you pass via AttachOptions.
import { createTelemetry } from "@streamwake/player-sdk";
import Hls from "hls.js";
const videoEl = document.querySelector("video");
if (!videoEl) throw new Error("No <video> element");
const handle = createTelemetry({
apiKey: "<your-api-key>",
ingestUrl: "https://streamwake.polsia.io/api/v1/telemetry",
session: {
contentId: "ckq3xepisodeabc123",
tags: { player: "web", env: "prod" },
},
options: { flushIntervalMs: 10_000, maxBatchSize: 50, debug: false },
});
const hls = new Hls();
hls.loadSource("https://example.com/manifest.m3u8");
hls.attachMedia(videoEl);
const detach = handle.attach(videoEl, { hls });
videoEl.addEventListener("ended", () => handle.end("ended"));
window.addEventListener("beforeunload", () => handle.end("unloaded"));sessionId— read-only id baked into every batch. Stable across the lifetime of onecreateTelemetry()call.emit(event)— push a custom event into the buffer (e.g. for ads or chapter switches the player doesn't fire natively).attach(videoEl, opts?)— subscribe to the media element + optionalhlsinstance. Returns adetach()cleanup.end(reason?)— flush the buffer and emit asession_endevent. Pass'ended','unloaded', or'error'.
Eight events, one discriminated union.
The player emits exactly the events below — one row per EventType value in the closed enum shared by src/lib/contracts/telemetry.ts. The server validates the union on POST; a malformed payload is rejected before any row is persisted.
| Event | When | Payload |
|---|---|---|
playback_start | Player started playback of a media element. | |
playback_pause | User paused playback. | |
playback_resume | User resumed playback after a pause. | |
rebuffer_start | Playback stalled because the buffer drained below the underrun threshold. | |
rebuffer_end | Playback resumed after a rebuffer. | |
quality_change | Adaptive bitrate (ABR) switched rendition — the QoE signal for stalls avoided / taken. | |
error | Player emitted a media error (decode, network, or src). | |
session_end | Session closed — natural end, unload, or fatal error. | |
What hits the wire.
Events are batched (defaults: every 10 s or 50 events) and POSTed to /api/v1/telemetry with the bearer API key in the Authorization header. The apiKey inside the body must match the bearer — a mismatch is a 401, so one client can't post events into another client's session.
On success the endpoint returns 204 No Content with an empty body — fire-and-forget. Retries are safe because the persisted key is (sessionId, ts, type), so a duplicated batch is a no-op.
better-auth.session_token cookie can't reach. The dashboard routes (/api/v1/streams, /api/v1/keys) use the cookie because they live on your domain.curl -X POST https://streamwake.polsia.io/api/v1/telemetry \
-H "content-type: application/json" \
-H "Authorization: Bearer <your-api-key>" \
-d '{
"apiKey": "<your-api-key>",
"sessionId": "ckq3xsessh1",
"events": [
{
"type": "playback_start",
"ts": "2026-08-04T18:24:11.000Z",
"payload": { "positionMs": 0, "durationMs": 1820000 }
}
]
}'HTTP/2 204 No Content{
"apiKey": "<your-api-key>",
"sessionId": "ckq3xsessh1",
"events": [
{
"type": "playback_start",
"ts": "2026-08-04T18:24:11.000Z",
"payload": {
"positionMs": 0,
"durationMs": 1820000
}
},
{
"type": "quality_change",
"ts": "2026-08-04T18:24:18.000Z",
"payload": {
"from": "auto",
"to": "720p",
"bitrate": 2400000
}
},
{
"type": "session_end",
"ts": "2026-08-04T18:39:14.000Z",
"payload": {
"reason": "ended",
"durationMs": 903000
}
}
]
}Mint once, store it forever.
API keys are minted through the same cookie-authed session you already use on the dashboard. POST a label to /api/v1/keys — the response contains the raw swk_… key exactly once. The server stores a SHA-256 hash; subsequent reads only expose the metadata.
For the full key surface — listing, soft-revoking via revokedAt, and the rest of the keys lifecycle — see the API reference.
curl -X POST https://streamwake.polsia.io/api/v1/keys \
-H "content-type: application/json" \
-b "better-auth.session_token=<your-session-cookie>" \
-d '{ "label": "Production web player" }'{
"id": "ckq3xkeyabc123",
"label": "Production web player",
"rawKey": "swk_<your-raw-key>",
"createdAt": "2026-08-04T18:21:02.000Z"
}The three response codes you'll see.
Invalid API key
Authorization header missing, malformed, or the apiKeyin the body doesn't match the bearer. Body is the verbatim { "error": "Invalid API key" }.
Validation
Zod rejected the batch envelope or one of the events. Body is { "errors": { "apiKey": "...", "sessionId": "...", "events": "..." } } — keys mirror the TelemetryBatchCreate contract.
Internal Server Error
Unexpected server failure. Body is { "error": "Internal Server Error" }. Safe to retry — events are idempotent on (sessionId, ts, type).
| Status | Body | When |
|---|---|---|
401 | | Authorization header missing, malformed, or the apiKey does not match any active record. The same body is returned when the apiKey in the body and the bearer in the header disagree. |
400 | | Zod validation failed on the batch envelope or on an individual event. Keys mirror the TelemetryBatchCreate contract: apiKey, sessionId, events. |
500 | | Unexpected server failure on the ingest path. Safe to retry the batch; events are idempotent by (sessionId, ts, type). |
From the player,
to the agent feed.
The SDK only POSTs to /api/v1/telemetry. Streams themselves are registered from the dashboard via the cookie-authed POST /api/v1/streams — mint a key here, drop it into your player, and the agent feed on /app/agents starts ingesting the moment the first batch lands.