Sign up, mint,
and authorize.
The auth flow has two clear halves. The cookie track covers everything you do in a browser — signing up, opening /app/streams, and minting an SDK key through /api/v1/keys. The bearer track covers what the player SDK does on a third-party origin — sending Authorization: Bearer swk_<your-key> on /api/v1/telemetry.
POST /api/v1/keys with your session cookie — the server returns the raw swk_… key exactly once.Authorization: Bearer swk_… on /api/v1/telemetry — never on /api/v1/streams (that route is cookie-gated).Sign up & sign in
Better-auth's emailAndPassword provider is already on. The fastest path is the UI at /sign-up. The form collects name, email, and password, then sets the better-auth.session_token cookie and navigates you to /app/streams.
Script it with REST instead at /api/auth/sign-up/email. On success the response body is shown below — note the Set-Cookie header that better-auth returns alongside it. Every /api/v1/* call from your browser carries that cookie automatically.
curl -X POST https://streamwake.polsia.io/api/auth/sign-up/email \
-H "content-type: application/json" \
-d '{
"name": "Your Name",
"email": "you@example.com",
"password": "choose-a-strong-password"
}'HTTP/2 200
Set-Cookie: better-auth.session_token=<opaque>; HttpOnly; SameSite=LaxWhere the key surfaces
Once you're signed in, the dashboard shell on /app/streams is also cookie-driven — useSession() from @/lib/auth-client reads the same session cookie. Nothing new to learn for the browser path.
The SDK key is a separate credential minted from that same session. POST a label to /api/v1/keys; the response body carries the raw swk_… key exactly once. The server stores a SHA-256 hash — subsequent reads expose only the metadata (id, label, createdAt, lastUsedAt, revokedAt).
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"
}Copy & rotate it
Copy. Treat the 201 body as the only readable copy of the key — pull the rawKeyvalue into your secret store / config / CI and you're done. There is no GET /api/v1/keys/<id> that re-emits the raw value — only the SHA-256 fingerprint is persisted.
Rotate. Two steps. First POST a new key (a new label per rotation keeps the dashboard list readable). Then DELETE the old: DELETE /api/v1/keys/<id> is a soft-revoke — it sets revokedAt = now() and returns HTTP/2 204. The row stays so audit history reads cleanly.
After rotation the old key stops working on the next request — verifyApiKey() in the telemetry ingest filters on revokedAt: null, so a revoked key fails authentication immediately. Same 401 body as a missing key — no separate "revoked" code.
curl -X DELETE https://streamwake.polsia.io/api/v1/keys/ckq3xkeyabc123 \
-b "better-auth.session_token=<your-session-cookie>"
# Response: HTTP/2 204 No ContentSoft revoke keeps the rotation visible — GET /api/v1/keys and the dashboard list show every key ever minted with its revokedAt timestamp. The SHA-256 fingerprint is never deleted, so an audit on "which player used which key at which time" is always reconstructable.
The bearer curl devs hit
Two curls, two contracts, two auth headers. The first runs POST /api/v1/streams — the dashboard route — and uses the session cookie. The second runs POST /api/v1/telemetry — the SDK ingest — and uses the bearer API key. Swap the auth header, swap the route.
/api/v1/streamsStreams dashboard — requires the better-auth.session_token cookie. A bearer key sent instead yields a 401 (the cookie gate still fires first).
curl -X POST https://streamwake.polsia.io/api/v1/streams \
-H "content-type: application/json" \
-b "better-auth.session_token=<your-session-cookie>" \
-d '{
"sourceUrl": "https://example.com/manifest.m3u8"
}'/api/v1/telemetryPlayer SDK ingest — requires the Authorization: Bearer swk_… header. The cookie is not required (and is not trusted) on this route.
curl -X POST https://streamwake.polsia.io/api/v1/telemetry \
-H "content-type: application/json" \
-H "Authorization: Bearer swk_<your-raw-key>" \
-d '{
"apiKey": "swk_<your-raw-key>",
"sessionId": "ckq3xsessh1",
"events": [
{
"type": "playback_start",
"ts": "2026-08-04T18:24:11.000Z",
"payload": { "positionMs": 0, "durationMs": 1820000 }
}
]
}'HTTP/2 204 No ContentEvery body, verbatim.
Across all auth-touching routes covered above — /api/v1/keys, /api/v1/streams, /api/v1/keys/<id>, and /api/v1/telemetry. Bodies quoted verbatim from the route handlers — grep this table when an error code comes back.
| Status | Body | When |
|---|---|---|
401 | | On cookie-gated routes (/api/v1/streams, /api/v1/keys). Missing or expired session cookie. Verbatim from src/lib/require-auth.ts. Re-auth via /api/auth/sign-in/email and retry. |
401 | | On POST /api/v1/telemetry. Bearer header missing, malformed, the key does not match any active record, or the apiKey in the body and the bearer disagree. Verbatim from src/app/api/v1/telemetry/route.ts. |
400 | | Zod validation failed. For /api/v1/keys, the only field is `label` (≤120 chars). For /api/v1/telemetry, the envelope shape is apiKey, sessionId, events. |
404 | | Returned by GET /api/v1/streams/<id> when the id is unknown. |
The brief listed a 403 "wrong tenant" response, but no route currently produces a 403 — there is no tenant model. Keys are scoped per-user via SHA-256 lookup in verifyApiKey, so a wrong key fails the same way as a missing one: 401 {"Invalid API key"}. The page reflects only the codes the routes actually emit.
From a key,
to a watched stream.
Three pages stay mutually consistent — start with the contract /docs/api-reference (what your scripts actually call), the cookie mechanics at /docs/auth, and the player SDK ingest in depth at /docs/sdk/web.