Player SDK
Open-source telemetry SDK — npm and CDN

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.

1 · Install

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
npm install @streamwake/player-sdk hls.js
CDN
v0.1.0 live — install via npm or pin the CDN

The 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.

CDN
<script src="https://cdn.jsdelivr.net/npm/@streamwake/player-sdk@0.1.0/dist/index.js" type="module"></script>
Source
MIT

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-sdk
2 · Initialize

Wire 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.

Init example
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"));
What's in the handle
  • sessionId — read-only id baked into every batch. Stable across the lifetime of one createTelemetry() 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 + optional hls instance. Returns a detach() cleanup.
  • end(reason?) — flush the buffer and emit a session_end event. Pass 'ended', 'unloaded', or 'error'.
3 · Wire format

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.

EventWhenPayload
playback_startPlayer started playback of a media element.
{
  "positionMs": 0,
  "durationMs": 1820000,
  "contentId": "ckq3xepisodeabc123"
}
playback_pauseUser paused playback.
{
  "positionMs": 14280,
  "bufferedMs": 30000
}
playback_resumeUser resumed playback after a pause.
{
  "positionMs": 14280,
  "bufferedMs": 30000
}
rebuffer_startPlayback stalled because the buffer drained below the underrun threshold.
{
  "positionMs": 412910,
  "bufferedMs": 0
}
rebuffer_endPlayback resumed after a rebuffer.
{
  "positionMs": 413280,
  "bufferedMs": 12500
}
quality_changeAdaptive bitrate (ABR) switched rendition — the QoE signal for stalls avoided / taken.
{
  "from": "480p",
  "to": "720p",
  "bitrate": 2400000
}
errorPlayer emitted a media error (decode, network, or src).
{
  "code": "MEDIA_ERR_NETWORK",
  "message": "manifest fetch failed",
  "fatal": false
}
session_endSession closed — natural end, unload, or fatal error.
{
  "reason": "ended",
  "durationMs": 903000
}
4 · The batch envelope

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.

Auth note.The SDK uses a bearer API key because it runs on third-party origins where the dashboard'sbetter-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.
POST /api/v1/telemetry
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 }
      }
    ]
  }'
204 Response
HTTP/2 204 No Content
Sample batch — three events, one session
{
  "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
      }
    }
  ]
}
5 · Get an API key

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.

Raw key returned ONCE — store it now
POST /api/v1/keys
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" }'
201 Response — rawKey returned once
{
  "id": "ckq3xkeyabc123",
  "label": "Production web player",
  "rawKey": "swk_<your-raw-key>",
  "createdAt": "2026-08-04T18:21:02.000Z"
}
6 · Errors

The three response codes you'll see.

401

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" }.

400

Validation

Zod rejected the batch envelope or one of the events. Body is { "errors": { "apiKey": "...", "sessionId": "...", "events": "..." } } — keys mirror the TelemetryBatchCreate contract.

500

Internal Server Error

Unexpected server failure. Body is { "error": "Internal Server Error" }. Safe to retry — events are idempotent on (sessionId, ts, type).

StatusBodyWhen
401
{
  "error": "Invalid API key"
}
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
{
  "errors": {
    "events": "Array must contain at least 1 element(s)",
    "apiKey": "String must contain at least 1 character(s)",
    "sessionId": "String must contain at least 1 character(s)"
  }
}
Zod validation failed on the batch envelope or on an individual event. Keys mirror the TelemetryBatchCreate contract: apiKey, sessionId, events.
500
{
  "error": "Internal Server Error"
}
Unexpected server failure on the ingest path. Safe to retry the batch; events are idempotent by (sessionId, ts, type).
Ready to wire it up?

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.