API keys
Two tracks: cookie for the dashboard, bearer for the SDK

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.

Mint at POST /api/v1/keys with your session cookie — the server returns the raw swk_… key exactly once.
Use the key as Authorization: Bearer swk_… on /api/v1/telemetry — never on /api/v1/streams (that route is cookie-gated).
Step 01Cookie track

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.

Open /sign-upSame-origin, no CORS to configure.
POST /api/auth/sign-up/email
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"
  }'
200 Response — Set-Cookie: better-auth.session_token=…
HTTP/2 200
Set-Cookie: better-auth.session_token=<opaque>; HttpOnly; SameSite=Lax
Step 02Cookie track → mint

Where 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).

The raw key is returned only in the 201 body below. If you lose it, mint a new one — there is no surface that re-emits the raw value.
POST /api/v1/keys (cookie session)
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"
}
Step 03Lifecycle

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.

DELETE /api/v1/keys/<id> — soft revoke
curl -X DELETE https://streamwake.polsia.io/api/v1/keys/ckq3xkeyabc123 \
  -b "better-auth.session_token=<your-session-cookie>"

# Response: HTTP/2 204 No Content
Why a soft revoke and not a hard delete

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

Step 04Run it

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.

POST
/api/v1/streams
Cookie track

Streams dashboard — requires the better-auth.session_token cookie. A bearer key sent instead yields a 401 (the cookie gate still fires first).

Cookie-gated
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"
    }'
POST
/api/v1/telemetry
Bearer track

Player SDK ingest — requires the Authorization: Bearer swk_… header. The cookie is not required (and is not trusted) on this route.

Bearer-gated
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 }
      }
    ]
  }'
204 Response
HTTP/2 204 No Content
Errors

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

StatusBodyWhen
401
{
  "error": "Unauthorized"
}
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
{
  "error": "Invalid API key"
}
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
{
  "errors": {
    "label": "String must contain at most 120 character(s)"
  }
}
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
{
  "error": "Not Found"
}
Returned by GET /api/v1/streams/<id> when the id is unknown.
On the 403 in the brief

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.

Keep reading

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.