Read the contract
before integration.
The checked-in @streamwake/player-sdk source describes how a future public package would attach 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. The supported surface today is this in-app wire-format reference; public npm and CDN distribution are not currently verified.
Claim status labels: see definitions.
Trace the wire format.
The checked-in source is organized around @streamwake/player-sdk. Its intended browser integration uses 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 local source and parity tests are present in this checkout. The types in packages/player-sdk/src/types.ts are the same TelemetryEvent discriminated union the server validates against.
The implementation and event types live in packages/player-sdk/src . The app-side mirror lives in src/lib/api-spec.ts. Use these local files to evaluate the contract; no public download artifact is implied.
Public npm, CDN, and repository distribution were not verified for this deployment. This page documents the supported in-app contract only; use the local source for controlled evaluation until a public artifact is confirmed.
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.
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.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.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.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 apiKey in 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.